Skip to main content

Building modules

Every business feature in Modulix is a module: a package with a manifest, optional tables, a NestJS server half and a React web half. This guide builds one from scratch, then walks through the Notes module (modules/notes), the complete reference implementation.

Read Architecture first if you have not. For the full list of hooks, slots and services, see the Plugin and extension API.

Before you begin​

  • A working development setup: see Deploying Modulix → Local development.
  • Node.js 22.12 or later and pnpm (the repository pins the pnpm version in package.json).
  • A module id: lowercase letters, digits and hyphens, matching ^[a-z][a-z0-9-]{1,48}$. The id becomes the prefix of your permission keys and table names, and your API is served at /api/v1/<id>. Ids used by core routes are reserved (core, auth, me, users, roles, permissions, tenants, settings, modules, api-keys, audit, health, docs).

1. Scaffold​

node packages/cli/bin/modulix.js module:new invoices --name "Invoices"
pnpm install
pnpm --filter @modulix/module-invoices db:generate --name init
pnpm --filter @modulix/module-invoices build

The scaffolder creates modules/invoices and adds it to the dependencies of apps/api and apps/web. That dependency is what makes the hosts discover it. Use --plugin to create it under plugins/ instead, or --dir <path> to choose another directory.

modules/invoices/
package.json "modulix" field: manifest / server / web / routes / migrations
src/manifest.ts static description (isomorphic)
src/schema.ts tables (portable DSL)
src/server/index.ts NestJS module + hooks + lifecycle
src/web/index.tsx web entry: slots, commands, messages
src/web/routes/ TanStack file routes, mounted at /m/invoices
migrations/{pg,mysql} generated SQL

2. Manifest​

The manifest is a static description of the module. It is loaded by the API, the CLI and tooling.

src/manifest.ts
export default defineModule({
id: 'invoices', // permission/table prefix, API path /api/v1/invoices
name: 'Invoices',
version: '0.1.0', // must equal package.json version
kind: 'module', // 'module' | 'plugin' | 'system'
requires: { core: '^0.1.0', modules: { crm: '^1.0.0' } },
permissions: [{ key: 'invoices.invoice.read', label: 'View invoices' }],
defaultGrants: { admin: ['invoices.*'], member: ['invoices.invoice.read'] },
menu: [{ id: 'invoices', label: 'Invoices', icon: 'Receipt', to: '/m/invoices', permission: 'invoices.invoice.read' }],
settings: {
tenant: z.object({ prefix: z.string().default('INV-').describe('Number prefix') }),
user: z.object({ compactList: z.boolean().default(false) }),
},
})

Rules:

  • Permission keys must start with <id>..
  • Every settings field needs a default. The settings UI is generated from the schema; .describe() provides the label.
  • The manifest must not import server-only or browser-only code.
  • defaultGrants are applied to an organization's roles when it enables the module. The Read-only system role gets every *.read permission unless defaultGrants.readonly says otherwise.

3. Tables​

Tables use the portable DSL from @modulix/db, so one definition works on PostgreSQL and MySQL.

src/schema.ts
export const invoices = defineTable(
'invoices_invoice', // prefix tables with the module id
(c) => ({
id: c.id(), // UUIDv7, generated by the app
tenantId: c.tenantId(), // required for tenant-owned data
number: c.varchar('number', 40).notNull(),
total: c.decimal('total', 18, 2).notNull(),
meta: c.json<{ notes?: string }>('meta'),
...c.timestamps(),
}),
(t, c) => [c.uniqueIndex('invoices_invoice_number_uq').on(t.tenantId, t.number)],
)

After every schema change, generate migrations for both dialects and commit them:

pnpm --filter @modulix/module-invoices db:generate --name <change>
warning

Never edit a migration that has already been applied. Add a new one instead.

4. Server​

The server half is a NestJS module. Its controllers are mounted under /api/v1/<id>.

src/server/index.ts
const REPO = Symbol('invoices.repo')

@Controller() // → /api/v1/invoices
class InvoicesController {
constructor(
@Inject(REPO) private readonly repo: TenantRepository<typeof invoices>,
@Inject(SETTINGS) private readonly settings: SettingsApi,
@Inject(AUDIT) private readonly audit: AuditApi,
) {}

@Get() @RequirePermission('invoices.invoice.read')
list() { return this.repo.findMany() } // always filtered by the current tenant
}

@Module({ controllers: [InvoicesController], providers: [tenantRepositoryProvider(REPO, invoices)] })
class InvoicesModule {}

export default defineServerModule({
module: InvoicesModule,
hooks(bus) { bus.on('crm.customer.deleted', async ({ customerId }) => { /* … */ }) },
lifecycle: { onInstall: ({ log }) => log('ready'), onUpgrade: async (ctx, from, to) => {} },
})

What the platform gives you:

NeedUse
Tenant-safe CRUDtenantRepositoryProvider + TenantRepository (findMany, findById, count, insert, update, delete)
Custom queries@InjectDb() → DatabaseHandle; filter with tenantScope(table, …conditions)
TransactionsinTransaction(db, async () => { … }); repositories join automatically
Current user and tenantrequestContext.get() or the @Ctx() parameter decorator
SettingsSETTINGS token → settings.tenant<T>('invoices')
Audit trailAUDIT token → audit.record({ moduleId, action, entityType, entityId })
EventsHOOKS token → emit / apply; register handlers in hooks(bus)
Validation and OpenAPIcreateZodDto(z.object({…}))
Authorization@RequirePermission(). Module scope (404 when disabled) is applied automatically

Server entries are compiled with tsc. NestJS needs emitDecoratorMetadata, so the module's tsconfig.json extends @modulix/config/tsconfig.node.json.

5. Web​

The web entry contributes slot components, commands for the ⌘K palette and the module's translations:

src/web/index.tsx
export default defineWebModule({
id: 'invoices',
slots: {
'dashboard.widgets': [{ id: 'invoices.overdue', component: OverdueWidget, size: 'md', permission: 'invoices.invoice.read' }],
'entity.crm.customer.tabs': [{ id: 'invoices.customer', component: CustomerInvoicesTab }],
},
commands: [{ id: 'invoices.new', label: 'New invoice', to: '/m/invoices/new', permission: 'invoices.invoice.write' }],
messages, // see "Languages and formats" below
})

Pages go in src/web/routes/ and follow TanStack file-routing conventions. Write the route id the generator expects:

src/web/routes/index.tsx
export const Route = createFileRoute('/_app/m/invoices/')({ component: InvoicesPage })

Inside components, use:

  • usePlatform(), useApi(), useCan() and <Can>
  • useApiQuery(), whose query keys are scoped to the tenant (or useTenantKey() with plain TanStack Query)
  • useSettings('invoices', 'tenant' | 'user')
  • useT('invoices'), useT('common') and useFormat() (see below)
  • components from @modulix/ui/components/*, styled only with semantic tokens (bg-card, text-muted-foreground, ...)

Languages and formats​

The platform speaks English, French, German and Spanish. Each person's language and time zone come from their profile, then the organization's settings, then their browser; the organization also sets the date format and its default currency. Ship your texts as one catalog per language. English is the source and the fallback:

src/web/messages.ts
import { defineMessages } from '@modulix/sdk'

const en = {
title: 'Invoices',
'overdue_one': '{count} overdue invoice',
'overdue_other': '{count} overdue invoices',
// What your manifest declares, shown by the shell and other modules:
'module.name': 'Invoices',
'menu.invoices': 'Invoices',
'command.invoices.new': 'New invoice',
'permission.invoices.invoice.read': 'View invoices',
'settings.paymentTerms': 'Payment terms (days)',
'entity.invoices.invoice': 'Invoice',
'entity.invoices.invoice.total': 'Total',
}
type Keys = Record<keyof typeof en, string>

export const messages = defineMessages({
en,
fr: { … } satisfies Keys, // a missing key is a type error
de: { … } satisfies Keys,
es: { … } satisfies Keys,
})
const t = useT('invoices')
const tc = useT('common') // Save, Cancel, Delete, Loading…, Something went wrong…
const fmt = useFormat()
t('overdue', { count: 3 }) // plural forms follow the language's rules
fmt.date(invoice.dueDate) // the organization's date format; YYYY-MM-DD dates never shift
fmt.relative(invoice.updatedAt) // "3 minutes ago", in the person's time zone
fmt.money(invoice.total, invoice.currency)
fmt.bytes(file.size)
tip

Never call toLocaleString, Intl.* or build plurals by hand in UI code. Use useFormat() and useT().

Labels another module declares are shown with useTranslate(): tr(moduleId, 'entity.' + key, label) falls back to the manifest's English label. The shell uses the conventional keys for menus, the command palette, the modules and roles pages and the settings forms (settings.<key>, enum options settings.<key>.<value>). The conventional keys are listed on useTranslate in packages/sdk-web/src/i18n.ts.

Server texts (notifications, emails) go in defineServerModule({ messages }) and are translated per recipient. Pass keys with the notification and the plain text as a fallback:

await hooks.emit('notification.send', {
tenantId, type: 'invoices.invoice.overdue', userIds,
title: `Invoice ${n} is overdue`,
i18n: { title: 'invoices:overdue.title', params: { number: n } },
}, { tenantId })

I18N.preferences(tenantId, userId) and I18N.t(locale, 'invoices:key', params) (from @modulix/sdk-server) cover other server texts. API error messages stay in English.

6. Make your records extensible​

Declare the record types other modules may extend (custom fields, workflow triggers, webhooks), with the fields of the snapshot you send in hooks:

src/manifest.ts
entities: [
{
key: 'invoices.invoice',
label: 'Invoice',
permissions: { read: 'invoices.invoice.read', write: 'invoices.invoice.write' },
fields: [
{ key: 'number', label: 'Number', type: 'string' },
{ key: 'total', label: 'Total', type: 'number' },
],
},
],

Then keep three promises:

  1. Form: render the generic entity.form slot in the create/edit form, and save its contributions after your own record:

    const ext = useFormExtensions()
    const submit = async () => {
    const problems = await ext.validate() // e.g. a required custom field is empty
    if (problems.length) return toast.error(problems[0])
    const saved = await save(values)
    await ext.commit(saved.id) // contributions save against the new id
    }
    <FormExtensionsProvider value={ext}>
    …your fields…
    <Slot name="entity.form" props={{ entity: 'invoices.invoice', entityId: invoice?.id ?? null }} />
    </FormExtensionsProvider>
  2. Details: render <Slot name="entity.details" props={{ entity: 'invoices.invoice', entityId }} /> on the detail view, if you have one.

  3. Lifecycle: emit the entity hooks with a snapshot of the record:

    await hooks.emit('entity.created', { tenantId, entity: 'invoices.invoice', id, record: entitySnapshot(row) }, { tenantId })
    await hooks.emit('entity.updated', { tenantId, entity: 'invoices.invoice', id, ...entityChange(before, after) }, { tenantId })
    await hooks.emit('entity.deleted', { tenantId, entity: 'invoices.invoice', id, record: entitySnapshot(row) }, { tenantId })

    Emit entity.updated only when changes is not empty.

Contributions check the declared read and write permissions themselves. The entity hooks also drive the audit log's before and after values and the organization's outgoing webhooks (invoices.invoice.created, ...).

  • To let other modules (such as workflows) run operations of yours, declare actions and register their handlers: see Module actions. Notes' notes.note.create is the reference.
  • To notify people, declare notifications in the manifest and emit notification.send: see Notifications.

Record types in a few lines​

Most business records (products, orders, invoices) need the same things: CRUD endpoints with validation, lists with search, filters, saved views, sorting and paging, record access, audit, the entity hooks above, lookups for pickers, imports and exports, and workflow updates. Declare their fields once, in an isomorphic file, and let the record toolkit do the rest:

// src/invoices.ts (isomorphic)
export const INVOICE_FIELDS: RecordField[] = [
{ key: 'number', label: 'Number', type: 'string', required: true, max: 30, sort: true, search: true },
{ key: 'customerId', label: 'Customer', type: 'ref', ref: 'contacts.company', required: true },
{ key: 'total', label: 'Total', type: 'money', currencyField: 'currency', readonly: true },
{ key: 'currency', label: 'Currency', type: 'currency', createOnly: true },
{ key: 'status', label: 'Status', type: 'enum', options: [...], default: 'draft' },
]

// server: provide RecordPlatform, create the store in your service
this.invoices = platform.store(
{ moduleId: 'invoices', entity: 'invoices.invoice', name: 'invoice', table: invoices, fields: INVOICE_FIELDS,
permissions: { read: …, write: …, delete: … }, title: (r) => r.number, params: ['status'] },
{ prepare: (values, ctx) => { /* business rules: ctx.problem(path, code, message) */ },
afterWrite: async (row, ctx) => { /* child rows, same transaction */ },
beforeDelete: async (row) => { /* refuse (409) or clean up */ },
detail: async (view, row) => ({ ...view, lines: … }) },
)
export const InvoicesController = recordController('invoices', { entity, fields: INVOICE_FIELDS, permissions })
// web
<RecordList entity="invoices.invoice" path="/invoices/invoices" ns="invoices" fields={INVOICE_FIELDS} columns={[…]} empty={…} />
<RecordDialog … fields={INVOICE_FIELDS} />
<DetailsCard record={invoice} fields={INVOICE_FIELDS} ns="invoices" />
  • Field types: string, text, email, url, phone, integer, decimal, money, percent, date, datetime, boolean, enum, ref (a record of another type, checked through entity.lookup), user (a member), country, currency. readonly fields are computed, createOnly ones are set once, hidden ones are edited by your own controls.
  • Money and quantities are decimal columns (c.decimal) and strings in the API. Compute with dec from @modulix/sdk (exact, no floating point: dec.round(x, 2), dec.allocate) and read what people type with parseDecimalText.
  • The store answers list (q, filter, declared params, sort / dir, page / pageSize, record access by ownerField), detail, create / insert, update / patch, set (status changes of your actions, audited), remove (asks entity.inUse first) and lookup. It registers entity.lookup, entity.update (the workflow step Update a field) and imports and exports (matchField updates existing records).
  • recordController(path, …) exposes GET /fields, GET / (list), GET /lookup?q=, POST /, GET /:id, PATCH /:id and DELETE /:id. Pass { readonly: true } or { noDelete: true } to expose fewer.
  • Labels of field keys and choices come from your catalog (field.<key>, <key>.<value>); entityMessages() derives the entity.* keys from them.

The Catalog module (modules/catalog) is the reference for the record toolkit.

7. Ship it​

  1. Rebuild and restart, or run pnpm dev.
  2. A platform administrator installs the module in Administration → Modules (or node dist/cli.js modules:install <id>). This runs its migrations.
  3. Each organization enables it. Its menu, routes, API and widgets appear immediately.
  4. For a release, bump the version in package.json and in the manifest. Deploy, then run migrate, or select Upgrade in Administration → Modules.

Tour of the Notes module​

modules/notes is small enough to read in one sitting and exercises the main extension points: a manifest with permissions, settings, an extensible entity and an action; a tenant-scoped table; a controller and service that audit, emit hooks and register an action; a page with the entity.form slot; a dashboard widget; a ⌘K command; and translations in four languages.

modules/notes/
package.json "modulix" field, scripts (build, typecheck, db:generate)
drizzle.pg.config.ts drizzle-kit config for PostgreSQL migrations
drizzle.mysql.config.ts drizzle-kit config for MySQL migrations
migrations/pg/0000_init.sql
migrations/mysql/0000_init.sql
src/manifest.ts permissions, grants, entity, action, menu, settings
src/schema.ts the notes_note table
src/server/index.ts Nest module, hooks, lifecycle
src/server/notes.controller.ts
src/server/notes.service.ts
src/web/index.tsx dashboard widget, command, messages
src/web/api.ts TanStack Query hooks for the API
src/web/messages.ts en, fr, de, es catalogs
src/web/routes/index.tsx the /m/notes page
src/web/components/recent-notes-widget.tsx

package.json​

The modulix field tells the hosts where each half lives. The manifest and server entries point at compiled output in dist/; the web entry and routes point at TypeScript sources, because the web app bundles and type-checks module UIs itself.

modules/notes/package.json (excerpt)
{
"name": "@modulix/module-notes",
"version": "0.1.0",
"type": "module",
"modulix": {
"manifest": "./dist/manifest.js",
"server": "./dist/server/index.js",
"web": "./src/web/index.tsx",
"routes": "./src/web/routes",
"migrations": "./migrations"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"db:generate": "drizzle-kit generate --config drizzle.pg.config.ts && drizzle-kit generate --config drizzle.mysql.config.ts"
}
}

Framework packages (@nestjs/common, React, TanStack, @modulix/sdk-web, @modulix/ui, ...) are peer dependencies, so the module uses the host's single copy.

Manifest​

modules/notes/src/manifest.ts (excerpt)
export default defineModule({
id: 'notes',
name: 'Notes',
version: '0.1.0',
kind: 'module',
category: 'Productivity',
icon: 'StickyNote',
requires: { core: '^0.1.0' },
permissions: [
{ key: 'notes.note.read', label: 'View notes' },
{ key: 'notes.note.write', label: 'Create and edit notes' },
{ key: 'notes.note.delete', label: 'Delete notes' },
],
defaultGrants: {
admin: ['notes.*'],
member: ['notes.note.read', 'notes.note.write'],
},
entities: [{ key: 'notes.note', label: 'Note', permissions: { … }, fields: [ /* title, body, color, pinned */ ] }],
actions: [{ key: 'notes.note.create', label: 'Create a note', permission: 'notes.note.write', inputs: [ … ] }],
menu: [{ id: 'notes', label: 'Notes', icon: 'StickyNote', to: '/m/notes', permission: 'notes.note.read', order: 50 }],
settings: {
tenant: z.object({
defaultColor: z.enum(['yellow', 'blue', 'green', 'pink']).default('yellow').describe('Default note color'),
maxNotes: z.number().int().min(1).max(10000).default(1000).describe('Maximum notes per organization'),
}),
user: z.object({
showOnDashboard: z.boolean().default(true).describe('Show recent notes on the dashboard'),
}),
},
})

Points to notice:

  • Members can read and write notes, but only administrators (through notes.*) can delete them.
  • Declaring the notes.note entity is what lets Custom Fields add fields to notes, Workflow Automation trigger on them, and webhooks send notes.note.created, notes.note.updated and notes.note.deleted.
  • The settings need no UI code: Administration → Settings and the profile page render them from the zod schemas.

Table​

modules/notes/src/schema.ts
export const notes = defineTable(
'notes_note',
(c) => ({
id: c.id(),
tenantId: c.tenantId(),
title: c.varchar('title', 200).notNull(),
body: c.text('body'),
color: c.varchar('color', 20).notNull(),
pinned: c.boolean('pinned').notNull().default(false),
createdBy: c.uuid('created_by'),
...c.timestamps(),
}),
(t, c) => [c.index('notes_note_tenant_idx').on(t.tenantId, t.updatedAt)],
)

Each drizzle config sets process.env.DB_DIALECT before importing the schema, so db:generate writes native SQL for each dialect into migrations/pg and migrations/mysql.

Server module​

modules/notes/src/server/index.ts
@Module({
controllers: [NotesController],
providers: [NotesService, tenantRepositoryProvider(NOTES_REPO, notes)],
})
export class NotesModule {}

export default defineServerModule({
module: NotesModule,
hooks(bus) {
// Filter hook: contribute a dashboard widget id (rendered by the web half).
bus.filter('dashboard.widgets', (ids) => [...ids, 'notes.recent'])
// Action hook: react to our own event (other modules and plugins can too).
bus.on('notes.note.created', ({ noteId, title }) => { /* … */ })
},
lifecycle: {
onInstall: ({ log }) => log('notes: installed'),
onEnable: ({ log }, tenantId) => log(`notes: enabled for ${tenantId}`),
},
})

The controller (notes.controller.ts) is a plain Nest controller with @Controller() and no path, so it is served at /api/v1/notes. It validates bodies with createZodDto and guards each route with @RequirePermission:

RoutePermission
GET /api/v1/notesnotes.note.read
GET /api/v1/notes/:idnotes.note.read
POST /api/v1/notesnotes.note.write
PATCH /api/v1/notes/:idnotes.note.write
DELETE /api/v1/notes/:idnotes.note.delete

Service​

notes.service.ts shows the platform services working together. Creating a note:

modules/notes/src/server/notes.service.ts (excerpt)
async create(input: { title: string; body?: string; color?: string; pinned?: boolean }) {
const cfg = await this.settings.tenant<NotesSettings>('notes')
if ((await this.repo.count()) >= cfg.maxNotes)
throw new ConflictException(`Note limit (${cfg.maxNotes}) reached`)
const ctx = requestContext.get()!
const note = await this.repo.insert({
title: input.title,
body: input.body ?? null,
color: input.color ?? cfg.defaultColor,
pinned: input.pinned ?? false,
createdBy: ctx.userId ?? null,
})
await this.audit.record({ moduleId: 'notes', action: 'note.created', entityType: 'note', entityId: note.id })
await this.hooks.emit('notes.note.created', { tenantId: ctx.tenantId!, noteId: note.id, title: note.title }, { tenantId: ctx.tenantId })
await this.hooks.emit('entity.created', { tenantId: ctx.tenantId!, entity: 'notes.note', id: note.id, record: entitySnapshot(note) }, { tenantId: ctx.tenantId })
return note
}
  • this.repo is a TenantRepository: the count() and insert() calls are scoped to the current organization without any tenant_id in the code.
  • The organization's settings (defaultColor, maxNotes) come from SETTINGS, already resolved through defaults, platform and organization values.
  • update() computes entityChange(before, after) and emits entity.updated only when something changed. remove() emits entity.deleted so modules that extend notes can drop their data.
  • The service declares its own hook by augmenting ActionHooks in @modulix/sdk, so emitters and listeners are type-checked.
  • In onModuleInit, it registers the handler of the notes.note.create action with ACTIONS.register(…, { owner: 'notes' }). Workflows can then create notes as a step.

Web half​

  • src/web/index.tsx contributes the RecentNotesWidget to dashboard.widgets, a ⌘K command (notes.new, which opens /m/notes?new=1), and the message catalogs.
  • src/web/api.ts wraps the API with TanStack Query. Query keys come from useTenantKey('notes'), so cached data never leaks between organizations when the person switches.
  • src/web/routes/index.tsx is the /m/notes page (createFileRoute('/_app/m/notes/')). It hides buttons with <Can permission="notes.note.write"> and useCan(), and its create/edit dialog renders <Slot name="entity.form" props={{ entity: 'notes.note', entityId }} /> inside a FormExtensionsProvider, validating and committing contributions around its own save.
  • src/web/components/recent-notes-widget.tsx reads the person's showOnDashboard preference with useSettings('notes', 'user') and hides itself when it is off.
  • src/web/messages.ts holds the en catalog, including the conventional keys for everything the manifest declares (module.name, menu.notes, command.notes.new, permission.notes.note.read, settings.defaultColor, entity.notes.note.title, action.notes.note.create, ...), and fr, de and es catalogs typed with satisfies Keys.

Try it​

pnpm --filter @modulix/module-notes build
pnpm dev

Then, signed in as an administrator, enable Notes in Administration → Modules. The Notes menu entry, the dashboard widget and the API appear at once; disable the module and GET /api/v1/notes returns 404 for that organization.

Other reference modules​

ModuleShows
modules/catalogThe record toolkit (RecordField, platform.store, recordController, RecordList)
modules/contactsRecord rules (own / team / all), duplicate checks, imports and exports, custom field filters
modules/orgExtensible records with a detail view, its own hooks and slots
modules/leadsDepending on another module's services (requires.modules)
modules/formsPublic pages under /p/<id> and public API routes
modules/custom-fields, modules/automationModules built entirely on other modules' extension points