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.
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.
defaultGrantsare applied to an organization's roles when it enables the module. The Read-only system role gets every*.readpermission unlessdefaultGrants.readonlysays otherwise.
3. Tables
Tables use the portable DSL from @modulix/db, so one definition works on PostgreSQL and MySQL.
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>
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>.
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:
| Need | Use |
|---|---|
| Tenant-safe CRUD | tenantRepositoryProvider + TenantRepository (findMany, findById, count, insert, update, delete) |
| Custom queries | @InjectDb() → DatabaseHandle; filter with tenantScope(table, …conditions) |
| Transactions | inTransaction(db, async () => { … }); repositories join automatically |
| Current user and tenant | requestContext.get() or the @Ctx() parameter decorator |
| Settings | SETTINGS token → settings.tenant<T>('invoices') |
| Audit trail | AUDIT token → audit.record({ moduleId, action, entityType, entityId }) |
| Events | HOOKS token → emit / apply; register handlers in hooks(bus) |
| Validation and OpenAPI | createZodDto(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:
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:
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 (oruseTenantKey()with plain TanStack Query)useSettings('invoices', 'tenant' | 'user')useT('invoices'),useT('common')anduseFormat()(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:
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)
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:
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:
-
Form: render the generic
entity.formslot 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 emptyif (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> -
Details: render
<Slot name="entity.details" props={{ entity: 'invoices.invoice', entityId }} />on the detail view, if you have one. -
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.updatedonly whenchangesis 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
actionsand register their handlers: see Module actions. Notes'notes.note.createis the reference. - To notify people, declare
notificationsin the manifest and emitnotification.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 throughentity.lookup),user(a member),country,currency.readonlyfields are computed,createOnlyones are set once,hiddenones are edited by your own controls. - Money and quantities are decimal columns (
c.decimal) and strings in the API. Compute withdecfrom@modulix/sdk(exact, no floating point:dec.round(x, 2),dec.allocate) and read what people type withparseDecimalText. - The store answers
list(q,filter, declaredparams,sort/dir,page/pageSize, record access byownerField),detail,create/insert,update/patch,set(status changes of your actions, audited),remove(asksentity.inUsefirst) andlookup. It registersentity.lookup,entity.update(the workflow step Update a field) and imports and exports (matchFieldupdates existing records). recordController(path, …)exposesGET /fields,GET /(list),GET /lookup?q=,POST /,GET /:id,PATCH /:idandDELETE /: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 theentity.*keys from them.
The Catalog module (modules/catalog) is the reference for the record toolkit.
7. Ship it
- Rebuild and restart, or run
pnpm dev. - A platform administrator installs the module in Administration → Modules (or
node dist/cli.js modules:install <id>). This runs its migrations. - Each organization enables it. Its menu, routes, API and widgets appear immediately.
- For a release, bump the version in
package.jsonand in the manifest. Deploy, then runmigrate, 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.
{
"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
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.noteentity is what lets Custom Fields add fields to notes, Workflow Automation trigger on them, and webhooks sendnotes.note.created,notes.note.updatedandnotes.note.deleted. - The settings need no UI code: Administration → Settings and the profile page render them from the zod schemas.
Table
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
@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:
| Route | Permission |
|---|---|
GET /api/v1/notes | notes.note.read |
GET /api/v1/notes/:id | notes.note.read |
POST /api/v1/notes | notes.note.write |
PATCH /api/v1/notes/:id | notes.note.write |
DELETE /api/v1/notes/:id | notes.note.delete |
Service
notes.service.ts shows the platform services working together. Creating a note:
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.repois aTenantRepository: thecount()andinsert()calls are scoped to the current organization without anytenant_idin the code.- The organization's settings (
defaultColor,maxNotes) come fromSETTINGS, already resolved through defaults, platform and organization values. update()computesentityChange(before, after)and emitsentity.updatedonly when something changed.remove()emitsentity.deletedso modules that extend notes can drop their data.- The service declares its own hook by augmenting
ActionHooksin@modulix/sdk, so emitters and listeners are type-checked. - In
onModuleInit, it registers the handler of thenotes.note.createaction withACTIONS.register(…, { owner: 'notes' }). Workflows can then create notes as a step.
Web half
src/web/index.tsxcontributes theRecentNotesWidgettodashboard.widgets, a ⌘K command (notes.new, which opens/m/notes?new=1), and the message catalogs.src/web/api.tswraps the API with TanStack Query. Query keys come fromuseTenantKey('notes'), so cached data never leaks between organizations when the person switches.src/web/routes/index.tsxis the/m/notespage (createFileRoute('/_app/m/notes/')). It hides buttons with<Can permission="notes.note.write">anduseCan(), and its create/edit dialog renders<Slot name="entity.form" props={{ entity: 'notes.note', entityId }} />inside aFormExtensionsProvider, validating and committing contributions around its own save.src/web/components/recent-notes-widget.tsxreads the person'sshowOnDashboardpreference withuseSettings('notes', 'user')and hides itself when it is off.src/web/messages.tsholds theencatalog, 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, ...), andfr,deandescatalogs typed withsatisfies 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
| Module | Shows |
|---|---|
modules/catalog | The record toolkit (RecordField, platform.store, recordController, RecordList) |
modules/contacts | Record rules (own / team / all), duplicate checks, imports and exports, custom field filters |
modules/org | Extensible records with a detail view, its own hooks and slots |
modules/leads | Depending on another module's services (requires.modules) |
modules/forms | Public pages under /p/<id> and public API routes |
modules/custom-fields, modules/automation | Modules built entirely on other modules' extension points |