Building Modules

Modules are the building blocks of content in Adonis EOS. Each module is a reusable component with configurable properties.

Module Architecture

A module consists of:

  1. Backend Definition (app/modules/*.ts) – schema, configuration, and rendering mode

  2. Frontend Component (inertia/modules/*.tsx) – the UI component (static or React)

  3. Registration (start/modules.ts) – register with the module registry

Creating a Module

1. Generate Boilerplate

bash
node ace make:module hero-banner

This creates:

  • app/modules/hero_banner.ts

  • inertia/modules/hero-banner.tsx

2. Define Backend Schema & Rendering Mode

Edit app/modules/hero_banner.ts:

typescript
import BaseModule from '#modules/base'
import type { ModuleConfig } from '#types/module_types'

export default class HeroBannerModule extends BaseModule {
  /**
   * Rendering mode:
   * - 'static' (default in BaseModule): pure SSR, no client-side hydration
   * - 'react': SSR + hydration for interactive components
   *
   * Hero banners usually have interactive CTAs and richer behavior,
   * so we opt this module into React rendering explicitly.
   */
  getRenderingMode() {
    return 'react' as const
  }

  getConfig(): ModuleConfig {
    return {
      type: 'hero-banner',
      isCore: true, // Mark as core for eager loading (LCP)
      name: 'Hero Banner',
      description: 'Large hero with image background',
      icon: 'image',
      allowedScopes: ['local', 'global'],
      lockable: true,

      propsSchema: {
        title: {
          type: 'string',
          required: true,
          description: 'Heading text',
          translatable: true,
        },
        subtitle: {
          type: 'textarea',
          required: false,
          description: 'Subheading',
          translatable: true,
        },
        backgroundImage: {
          type: 'media',
          required: false,
          description: 'Background image',
        },
        ctaButton: {
          type: 'object',
          required: false,
          description: 'Call-to-action',
          properties: {
            label: { type: 'string', translatable: true },
            url: { type: 'link' },
          },
        },
      },

      defaultProps: {
        title: 'Welcome',
        subtitle: 'Build amazing content',
        backgroundColor: 'bg-backdrop-low',
      },

      allowedPostTypes: ['page', 'blog'],

      aiGuidance: {
        layoutRoles: ['hero', 'intro'],
        keywords: ['hero', 'banner', 'opening'],
        useWhen: ['You need a high-impact opening section.'],
        compositionNotes: 'Typically the first module on a page.',
      },
    }
  }
}

3. Build Frontend Component (Single documentation.tsx Convention)

The frontend component file always matches the module type:

  • type: 'hero-banner'inertia/modules/hero-banner.tsx

  • type: 'prose'inertia/modules/prose.tsx

We no longer use -static suffixes. Whether a module is static or React is controlled entirely by getRenderingMode() on the backend.

Edit inertia/modules/hero-banner.tsx:

tsx
interface HeroBannerProps {
  title: string
  subtitle?: string | null
  backgroundImage?: { url: string } | null
  ctaButton?: { label: string; url: string } | null
  backgroundColor?: string
}

export default function HeroBanner({
  title,
  subtitle,
  backgroundImage,
  ctaButton,
  backgroundColor = 'bg-backdrop-low',
}: HeroBannerProps) {
  return (
    <section className={`relative ${backgroundColor} py-24`} data-module="hero-banner">
      {backgroundImage && (
        <img
          src={backgroundImage.url}
          alt=""
          className="absolute inset-0 w-full h-full object-cover opacity-20"
        />
      )}

      <div className="relative max-w-7xl mx-auto px-4 text-center">
        <h1 className="text-5xl font-bold text-neutral-high mb-4">Building Modules</h1>

        {subtitle && <p className="text-xl text-neutral-medium mb-8">{subtitle}</p>}

        {ctaButton && (
          <a
            href={ctaButton.url}
            className="inline-block px-6 py-3 bg-standout text-on-high rounded-lg hover:bg-standout/90"
          >
            {ctaButton.label}
          </a>
        )}
      </div>
    </section>
  )
}

4. Registration & Auto-Discovery

Frontend auto-discovery (no manual mapping)

Module components in inertia/modules/*.tsx are auto-discovered using import.meta.glob. As long as you:

  • Export a default component from inertia/modules/documentation.tsx

  • Use the same type value in your backend module config

...the system will find and render your component automatically.

Performance & LCP (Core Modules vs Interactivity)

Adonis EOS provides two distinct ways to optimize module performance:

1. isCore (Bundling & Network Waterfall)

By default, modules are lazy-loaded to keep the initial JS bundle small. However, modules that often appear "above the fold" (like Hero sections) should be eagerly loaded to improve Largest Contentful Paint (LCP).

  • Purpose: Eliminates the "network waterfall" where the browser has to fetch a separate JS chunk before it can render the LCP image.

  • How to use:

    1. Set isCore: true in the backend getConfig().

    2. Add export const isCore = true at the top of your inertia/modules/documentation.tsx file.

    3. Restart the dev server or run npx tsx scripts/sync_modules.ts to update the core manifest.

The system automatically scans your modules and includes those marked as isCore in the main JavaScript bundle.

2. Interactivity Toggle (Main-thread Work & Hydration)

For "hybrid" rendering modules, editors can toggle "Add interactivity" in the admin.

  • Purpose: Reduces Total Blocking Time (TBT). When interactivity is off, the module is rendered as static HTML from the server and React skips the expensive "hydration" step on the client.

  • How to use: Use getRenderingMode() { return 'hybrid' } in your backend class.

Feature

Level

Focus

Result

isCore

Code

Network

Code is in the main bundle; no extra request.

Interactivity

Editor

CPU

React ignores the HTML; zero hydration cost.

Best Practice: For maximum performance, mark your Hero modules as isCore: true, and advise editors to only enable "interactivity" if animations or complex behaviors are truly needed for that specific page.

Backend registration

Update start/modules.ts:

typescript
import HeroBannerModule from '#modules/hero_banner'

moduleRegistry.register(new HeroBannerModule())

5. Restart Server

bash
# Stop server (Ctrl+C)
npm run dev

Your module is now available in the admin!

Property Types

Basic Types

typescript
{
  title: { type: 'string', required: true },
  description: { type: 'textarea', required: false },
  count: { type: 'number', required: false },
  isActive: { type: 'boolean', required: false },
}

Module Groups (Layout Templates)

Module Groups are reusable “page templates” that help editors start from a consistent layout.

Key files

  • Admin controller: app/controllers/module_groups_controller.ts

  • Models: app/models/module_group.ts, app/models/module_group_module.ts

  • Tables: module_groups, module_group_modules

Concepts

  • A module group contains an ordered list of module types and default props.

  • When used to create a post, the system can:

    • create modules in the correct order

    • mark some modules as locked (cannot be removed)

Developer workflow

  • Add/modify allowed modules by updating module definitions and post type config.

  • Use the admin UI to curate “starter layouts” for editors.

  • When adding new modules, consider updating common module groups.

Media Type

typescript
{
  image: {
    type: 'media',
    required: false,
    description: 'Feature image',
  }
}

Access in component:

tsx
{
  image && <img src={image.url} alt={image.alt_text} />
}

Link Type

typescript
{
  ctaUrl: {
    type: 'link',
    required: false,
  }
}

Links can point to:

  • External URLs

  • Internal posts (by slug)

  • Anchors (#section)

Object Type (Nested)

typescript
{
  button: {
    type: 'object',
    properties: {
      label: { type: 'string', translatable: true },
      url: { type: 'link' },
      style: {
        type: 'select',
        options: ['primary', 'secondary', 'outline']
      },
    },
  }
}

Array Type (Repeatable)

typescript
{
  features: {
    type: 'array',
    items: {
      type: 'object',
      properties: {
        icon: { type: 'string' },
        title: { type: 'string', translatable: true },
        description: { type: 'textarea', translatable: true },
      },
    },
  }
}

Access in component:

tsx
{
  features?.map((feature, i) => (
    <div key={i}>
      <h3>{feature.title}</h3>
      <p>{feature.description}</p>
    </div>
  ))
}

Module Scopes

  • local: Module is instance-specific to a post

  • global: Module is reusable across multiple posts

Advanced Features

Translatable Props

Mark props as translatable for i18n support:

typescript
{
  title: {
    type: 'string',
    translatable: true,  // Content varies by locale
  }
}

Post Type Restrictions

Limit module to specific post types:

typescript
{
  allowedPostTypes: ['blog', 'page'],  // Only for blogs and pages
}

Lockable Modules

Prevent accidental deletion:

typescript
{
  lockable: true,  // Users can lock critical modules
}

AI Guidance & Discovery

To help AI agents (like Cursor or n8n) understand when and how to use your module, provide structured guidance in getConfig():

typescript
aiGuidance: {
  // High-level roles this module can fulfill
  layoutRoles: ['features', 'content'],
  
  // Keywords that trigger this module during layout planning
  keywords: ['capabilities', 'grid', 'list'],
  
  // Bullets for the agent's decision making
  useWhen: [
    'You have a list of short features to display.',
    'You want a compact grid layout.'
  ],
  
  // Notes on pairing and placement
  compositionNotes: 'Works well after a Hero section.'
}

This information is exposed via the MCP server and used by the suggest_modules_for_layout tool.

Best Practices

  1. Use semantic HTML - <section>, <article>, <nav>

  2. Add data attributes - data-module="hero-banner" for testing

  3. Support dark mode - Use theme tokens and follow the contrast level convention (low, medium, high) to ensure seamless support for both light and dark site modes.

  4. Responsive design - Mobile-first approach

  5. Accessibility - ARIA labels, keyboard navigation

  6. Default props - Provide sensible defaults

  7. Validation - Mark required fields

  8. Naming Conventions - Use the word 'Prose' in your module type and name (e.g., prose, prose-with-media, company-prose) if the module is intended for long-form, rich text content. The system uses this keyword to signal AI agents that a substantial amount of copy (multiple paragraphs, headings, etc.) is expected.

  9. AI Discovery - Always include aiGuidance with relevant keywords and layoutRoles. This ensures your module is automatically suggested by the AI layout planner without needing to manually register it in core system code.

Testing Modules

Test your module in the admin:

  1. Go to /admin/posts

  2. Create or edit a post

  3. Click "Add Module"

  4. Select your new module

  5. Configure props and preview

Examples

See existing modules in app/modules/ and inertia/modules/ for reference implementations.