Distributable module
Most work on the DMS is a project that loads it and registers its own pages — see Project setup. The rarer case is packaging a distributable extension module that other projects install (the way the official cms-* modules do). The pages, components, DataTypes, auth, and lifecycle are identical to a project (the Building and Frontend sections); a module differs only in how it's packaged and loaded:
- a consumer installs it and adds it to their
antelope.config.tsas apackage/gitmodule, rather than wiring it as the project'slocalmodule; - it may define its own AntelopeJS interface so other modules can extend it — though many distributable modules publish no interface at all.
A DMS module is an ordinary AntelopeJS module that depends on @antelopejs-private/cms, registers a module + pages when it loads, and ships a Nuxt layer for any custom frontend.
Directory layout
One layout that works — the maintained modules use variations of it; structure yours however works best for you:
<module-name>/
src/
index.ts # entry — construct/start/stop/destroy + RegisterModule + AddNuxtLayer
db/ # schema / models / tables (if it owns data)
pages/ # @RegisterPage / @RegisterPageExtension → see Pages & components
routes/ # HTTP controllers feeding components → see Pages & components
services/ utils/ types/ # internal logic / constants
nuxt-layer/ # the module's frontend layer → see Frontend layer
nuxt.config.ts
app/{components,composables,types}/
i18n/locales/*.json
package.json
tsconfig.json
The lifecycle entry (src/index.ts)
The entry file has two jobs. Its side-effect imports run your decorators — @RegisterPage, @RegisterPageExtension, @RegisterDataType, the DataControllers — exactly as in a project. A module that only extends another module's pages (Extending another module's page) still needs those imports: the injection happens when the decorator runs, and applies whenever the target page registers, before or after. Its lifecycle exports — construct, start, stop, destroy, a framework concept (Creating a module) — place each registration at the right moment:
construct(config)receives the consumer's config and attaches the frontend layer (AddNuxtLayer); wiring an interface implementation (ImplementInterface) belongs here too.start()begins runtime work: provision the DB schema (RegisterSchema), hydrate caches, schedule cron.stop()/destroy()release those resources.
RegisterModule is the exception: it runs at import time, at the top level (details below).
import "./db"; // side-effect imports — decorators register on import
import "./pages";
import { RegisterSchema } from "@antelopejs/interface-database-decorators";
import { AddNuxtLayer, RegisterModule } from "@antelopejs-private/cms/interfaces/cms/page";
export interface Config {
/* whatever the consumer passes under this module's config */
}
let config: Config;
export const myModule = RegisterModule({
id: "<name>", // pages attach with module: "<name>"
title: "$your_module.title", // i18n key — see Localization
description: "$your_module.description",
icon: "i-ph-cube",
});
export async function construct(cfg: Config): Promise<void> {
config = cfg;
await AddNuxtLayer({
/* the module's frontend layer — see AddNuxtLayer below */
});
}
export async function start(): Promise<void> {
await RegisterSchema("<module-name>"); // provision the schema once the DB is up
}
export function stop(): void {
/* stop cron, deactivate subscriptions */
}
RegisterModule — the dashboard entry
RegisterModule(opts) registers a top-level module in the dashboard nav and returns a CategoryInfo. It runs at import time at the top level — typically in src/index.ts. It must fire before its pages register, so the module exists when they attach by setting module: "<id>". How pages attach, their URLs, and the access model are covered in Module navigation and access below.
import { RegisterModule } from "@antelopejs-private/cms/interfaces/cms/page";
export const demoModule = RegisterModule({
id: "demo", // pages attach with module: "demo"
title: "Demo Module", // use a "$cms_demo.title" i18n key in real modules
description: "A demo module to showcase the module system",
icon: "i-ph-cube",
// landingPage: "<page-id>", // page shown when entering the module
defaultCategory: {
// groups the module's loose pages under a heading
displayName: "Demo Pages",
icon: "i-ph-files",
order: 0,
},
});
Module navigation and access
A module ships its own separate sidebar — Navigation covers the dashboard tree it sits in. A page joins the module by setting module: "<id>", and its category decides where it lands inside: under a category descending from the module root (RegisterModule returns that root — nest yours under it), or under the module's defaultCategory — a generic "Pages" heading when none is declared. Three constraints are enforced at registration: the category must descend from the module root, module pages cannot use the Settings category (a module declares its settings pages inside its own sidebar), and the module must already exist — a page naming an unknown module throws.
Module pages resolve at /modules/<moduleId>/<urlSlug>. Entering the module lands on its landingPage when that page exists, otherwise on the first page by order; an empty module resolves to /modules/<id>.
router.push({ path }) to a module page must include the /modules/<moduleId> prefix, or the frontend catch-all 404s — even with a clean backend build. Prefer reading fullSlug from the page/site layout over hardcoding paths.Access is owner-only by design: the Modules group — and every page and category inside it — is visible to the platform owner alone, the holder of the * permission (Auth & permissions). On a SaaS deployment that means you, the maker — not your customers, who are tenant owners (SaaS mode). Page and component permission ids still exist, prefixed modules.<moduleId>.…, but they are module-scoped: they never appear in the grantable Roles tree, permission checks deny them to any non-owner regardless of stored role grants, and per-page permissions have no effect inside the module — the owner sees everything. Design module pages as owner/admin areas.
On the frontend, useCurrentModule() returns the module that owns the current route (Page context).
Expose an interface (optional)
A module can define its own AntelopeJS interface so other modules extend it. Authoring, implementing, and wiring interfaces is framework territory: Interfaces and Exporting interfaces are the reference. On the packaging side, declare each interface as an explicit exports subpath of your package.json so consumers can import it.
Frontend CLI
The frontend loader's command line — dev, build, start, prepare, clean, how dev auto-discovers the backend, and the per-backend workspaces.
Deployment
Run the DMS in production — build both processes, start them in the right order, and check off the secrets and multi-instance requirements.