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 dms-* 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/dms, registers a module and pages when it loads, and ships a Vue frontend module 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 + AddFrontendModule
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
frontend-vue/ # the module's Vue frontend module → see Frontend layer
dms.frontend.ts # entry — registers pages, components, plugins, the stylesheet
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 module (AddFrontendModule); 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 { resolve } from "node:path";
import "./db"; // side-effect imports — decorators register on import
import "./pages";
import { RegisterSchema } from "@antelopejs/interface-database-decorators";
import { AddFrontendModule, RegisterModule } from "@antelopejs/interface-dms/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 AddFrontendModule({
name: "my-module",
sourcePath: resolve(__dirname, "../frontend-vue"),
renderer: { name: "vue", version: "3" },
});
}
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/interface-dms/page";
export const demoModule = RegisterModule({
id: "demo", // pages attach with module: "demo"
title: "Demo Module", // use a "$dms_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 — every time, wherever the user was in the module before; an empty module resolves to /modules/<id>.
The module's own address, /modules/<moduleId>, is served by the DMS as a redirect to that landing page — the first page the caller may open when landingPage is closed to them — keeping the query and the hash it was opened with (/modules/api?live=0 opens the landing page paused). A server render answers it with a redirect; in the browser (a typed URL, a sign-in coming back, the module's breadcrumb crumb) the navigation goes straight to the landing page, so the module's address never enters the history. A module needs no hidden page of its own there.
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).
The module manifest (antelopeJs in package.json)
The AntelopeJS core reads its module manifest from the antelopeJs key of the module's package.json. Two fields matter for packaging:
{
"name": "@acme/dms-invoices",
"antelopeJs": {
"baseUrl": "dist",
"implements": ["@acme/interface-dms-invoices", "@acme/dms-invoices"]
}
}
baseUrlpoints at the compiled output the core loads.implementslists every package name this module provides at runtime. There is noimportscounterpart: what a module consumes is its ordinarydependencies.
At startup the core walks each loaded module's dependencies, and treats a dependency as an interface dependency as soon as the resolved package has an antelopeJs key of its own — which every AntelopeJS module has, runtime packages included. Every such dependency must be listed in some loaded module's implements, or the boot fails with:
Unresolved interface dependencies:
- <module> requires <package>
implements.A runtime package with an antelopeJs manifest that consumers also import from — so they list it in dependencies — plays both roles at once, and the check above only reconciles them through self-declaration: "implements": ["@acme/interface-dms-invoices", "@acme/dms-invoices"]. Prefer not needing it. @antelopejs/dms declared its own name while consumers imported page classes from @antelopejs/dms/pages; extensions now name their target page by id (Extending another module's page), nothing but the interface package is imported, the ./pages export is gone, and its manifest lists ["@antelopejs/interface-dms"] alone.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 ajs dms commands that run, build and serve the DMS frontend, in short — the full reference lives in the dms-frontend documentation.
Deployment
Run the DMS in production — build both processes, start them in the right order, and check off the secrets and multi-instance requirements (Redis, affinity on the realtime routes).