Frontend layer
Most admin screens need no frontend code — the backend describes them and the Inertia/Vue frontend renders them (Architecture). A frontend module is optional: you add one only when a screen needs a custom component the standard builders don't cover, or to override a component the DMS already ships. ("Layer" is the older name for the same unit; the loader calls it a frontend module.)
That code lives in frontend-vue/, registered with AddFrontendModule (Project setup) and served by the frontend loader that ajs dms runs (Frontend CLI). This page covers what a dashboard project writes in that module, and how the loader turns it into rendered pages.
For a concrete result, see the custom retention input: its Vue component adjusts a value, displays a validation error and asks for confirmation, while the DMS form still owns saving.
dms.server.ts, dms.frontend.build.ts, server routes, route rules), the server rendering model and the frontend server's deployment — lives in the Frontend section.Layout
A frontend module is a plain npm package with one required file — dms.frontend.ts at its root — and a set of directories the loader discovers by convention:
frontend-vue/
package.json # "type": "module", "main": "./dms.frontend.ts", engines
dms.frontend.ts # the entry: registers everything explicitly
app/
app.config.ts # discovered by convention — see Theming & branding
components/ # Vue SFCs, registered by name from the entry
composables/ # useXxx composables — auto-imported, see Auto-imports
plugins/ # defineDmsPlugin setups, registered from the entry
utils/ # helpers
config/
shortcuts-registry.ts # discovered by convention — see UI toolkit
i18n/
locales/<module>-<ll>-<CC>.json # discovered by convention — see Localization
Only dms.frontend.ts, app/app.config.ts (from @antelopejs/dms-frontend 0.6.0, see Theming & branding), app/config/shortcuts-registry.ts, i18n/locales/*.json and public/ are found by the loader on their own. Everything else — components, plugins, middleware — reaches the dashboard because the entry registers it explicitly. There is no nuxt.config.ts and no extends chain; which directories are auto-imported is covered in Auto-imports.
layers/<name>/ — that is how the DMS itself is organised (dms-core, dms-ui, dms-auth, …). The split is all-or-nothing: once layers/ exists, the loader scans the sub-roots and stops scanning the module root. dms.frontend.ts stays at the module root either way, and a module's own layers/<name>/ also becomes the #<name>/* import alias.The module entry
dms.frontend.ts default-exports a DmsFrontendModule: an object with one setup(sdk) method the loader awaits once, in priority order, while the app boots, and an optional componentPrefix (see Component naming).
import { type Component, defineAsyncComponent } from "vue";
import type { DmsFrontendModule } from "#dms/frontend-module";
import ratingDataType from "./app/plugins/rating-data-type.client";
interface VueModule {
default: Component;
}
// Every component is addressed by name from the backend, so registering the
// whole directory keeps the two sides in sync without a manual list.
const components = import.meta.glob<VueModule>("./app/components/**/*.vue");
const frontendModule: DmsFrontendModule = {
// Put in front of every name registered below: GraphCanvas.vue registers
// as YourModuleGraphCanvas.
componentPrefix: "YourModule",
setup(sdk) {
for (const [path, loader] of Object.entries(components).sort()) {
const name = path.split("/").at(-1)!.replace(/\.vue$/, "");
sdk.registerComponent(
name,
defineAsyncComponent(async () => (await loader()).default),
);
}
// Client-only: the display it registers carries callables, which the SSR
// payload strips.
sdk.registerPlugin(ratingDataType, { clientOnly: true });
},
};
export default frontendModule;
The sdk handed to setup is the module's whole contract with the dashboard:
| Member | What it does |
|---|---|
options | The module's public options, as { public } — see How module config reaches Vue |
registerComponent(name, component, options?) | Make a component resolvable by the name the backend puts in a serialized tree, behind the module's componentPrefix; { prefix: false } registers the name as given (see Overriding an existing component) |
registerPage(name, component, preload?) | Register a full Inertia page component |
registerDynamicPage(name, component, preload?) | Register a renderer for a backend-described page kind |
registerLayout(name, component, preload?) | Register a page layout |
registerErrorPage(component, preload?) | Replace the error page |
registerPlugin(setup, { clientOnly }) | Run a defineDmsPlugin setup at boot, optionally on the client only |
registerMiddleware(name, handler, { global }) | Add navigation middleware, named or global |
provide(key, value) | Provide a value to the whole Vue app |
use(plugin) | Install a Vue plugin |
Every registration is first-write-wins: a name already taken by an earlier module is left alone. That is what makes priority meaningful — see Overriding an existing component.
The frontend SDK
Anything the dashboard exposes to a module comes from the #dms/frontend-module alias, which the generated workspace maps to its own runtime. Import from it explicitly.
import {
DmsLink,
useDmsAsyncData,
useDmsRoute,
useDmsRouter,
useDmsRuntimeConfig,
} from "#dms/frontend-module";
The alias also carries defineDmsPlugin, defineDmsMiddleware, useDmsFetch, useDmsData, useDmsState, useDmsCookie, useI18n, useHead / useSeoMeta, navigateDms, resolveDmsComponent and the Dms* types the registrations take. The composables documented in Frontend composables are reached the same way.
State is composable-based — there is no global store (no Vuex or Pinia). Follow the existing modules' composables (useXxx) rather than introducing one.
Auto-imports
An auto-imported export is available to the whole application, in scripts and templates, without an import. Up to @antelopejs/dms-frontend 0.3, the generated Vite config auto-imported every module's app/composables, app/types and app/utils, plus app/build/composables and app/build/types.
From @antelopejs/dms-frontend 0.4 (the DMS requires 0.5), auto-imports are opt-in. A module declares its auto-imported directories in an optional dms.frontend.build.ts, next to dms.frontend.ts:
import { defineDmsFrontendBuild } from "#dms/frontend-build";
export default defineDmsFrontendBuild((build) => {
build.registerAutoImports(["app/composables", "app/utils", "app/types"]);
});
- Each entry is a directory relative to the module root, glob patterns allowed (
layers/*/app/composablesfor a module split into layers); every export of the files under it is auto-imported. - A module without the file has nothing auto-imported. A module that relies on auto-imports adds the file before it upgrades.
app/build/is never auto-imported: it is left out of a broader entry, and an entry naming it, or a directory outside the module, is ignored with a warning when Vite starts. Importapp/build/code by path.- The file runs in Node.js when Vite starts (
ajs dms dev,ajs dms build), not in the application: it imports#dms/frontend-buildand Node.js built-ins only. Vite restarts when it changes.
What the DMS layer exposes
In each of the DMS's own layers (dms-core, dms-ui, dms-layout, …), the root components/, composables/, utils/ and types/ folders are the public API: their components are global, their exports auto-imported, and a module may rely on them. build/ holds the DMS's internals: nothing under it is a contract and its exports are not auto-imported (the DMS's dms.frontend.build.ts declares the root folders only). Its components are registered like the others, under the Dms prefix, so the DMS's backend pages can name them, but that does not make them API: a module that renders or imports one depends on internals that change without notice.
The DMS's composables take the plain use<Thing> name, with no Dms prefix: useSiteLayout, useModal, useDevReload. The Dms prefix belongs to the #dms/frontend-module runtime, which carries it on every generic API it defines (useDmsFetch, useDmsRoute, useDmsState, useDmsUserSession, useDmsError, defineDmsAppConfig); only the upstream APIs it re-exports as they are keep their names (useHead, useSeoMeta, useI18n, $fetch).
Component naming
A component's name is the module's componentPrefix followed by the string you pass to sdk.registerComponent, and that full name is the string the backend puts in a CustomComponent(...) call or a DataType's input declaration. With componentPrefix: "YourModule", registerComponent("GraphCanvas", ...) registers YourModuleGraphCanvas, and the backend sends CustomComponent("YourModuleGraphCanvas"). The DMS's own prefix is Dms. A module without a componentPrefix registers names as given.
Lookups are normalized: a leading lazy and every non-alphanumeric character are stripped and the rest is lowercased, so YourModuleGraphCanvas and your-module-graph-canvas resolve to the same component. Up to @antelopejs/dms-frontend 0.4 a leading dms was stripped too; from 0.5 it is kept, since it is the DMS's prefix: DmsTable and another module's Table are two components.
- Pick a prefix of your own, so your names never collide with another module's. When two modules still register the same full name, the module of higher priority keeps it and the browser console warns, naming both.
- A component the registry cannot find renders a bare
<div>: the tree resolver falls back to"div"rather than failing. A pane that comes up empty with no console error is a name mismatch first, a broken component second.
Overriding an existing component
Modules compose: every loaded module's registrations coexist, and adding a module adds components. Replacing one the DMS ships is an explicit act — and the heavy one. For logos, colors and design tokens, prefer Theming & branding.
When you do need a real override, it takes two conditions:
- Register the same full name, without your prefix.
sdk.registerComponent("DmsFoo", MyFoo, { prefix: false })replaces the DMS'sDmsFoo; a registration made that way is not reported as a collision. A different name produces a different component, not an override. - Give your module a higher
priority. The loader materializes modules in descending priority order and sets them up in that order, and the first registration of a name wins. The base module sits atpriority: 0; usepriority: 1to take a name from it. Equal priorities preserve manifest order, so use distinct priorities rather than relying on registration order.
After changing a module's priority, regenerate the frontend workspace.
How module config reaches Vue
AddFrontendModule (Project setup) is the backend half of the contract. It names the module, points at its source directory, declares the renderer it targets, and carries its configuration:
await AddFrontendModule({
name: "@antelopejs/my-module",
sourcePath: resolve(__dirname, "../frontend-vue"),
renderer: { name: "vue", version: "3" },
configKey: "myModule",
priority: 1,
options: { readOnly: true }, // PUBLIC → runtimeConfig.public.myModule
privateOptions: { apiKey: secret }, // served to an authenticated loader only
});
renderer is not decoration: the loader requests the frontend manifest for one renderer at a time and rejects modules registered for another. { name: "vue", version: "3" } is the only pair any shipped loader answers to today; other frameworks would register under their own renderer name.options are public. The loader merges them into the frontend runtime config, nested under configKey when one is given, and hands the same object to your entry as sdk.options.public:
const { readOnly } = useDmsRuntimeConfig().public.myModule;
privateOptions are secrets the backend serves only to an authenticated loader. The loader consumes none of them itself and the Vue renderer does not surface them to a frontend module — they land in the manifest cache, which is why that file is written 0600.
frontend.bootstrapSecret gate. A backend that configures one refuses a loader that does not present it, so the build fails rather than producing a frontend missing its privateOptions; a backend that configures none serves both to anyone, stripped of every private option, and the key is simply absent from runtimeConfig[configKey] — read it defensively, or state the requirement in your module's setup docs. Either way a module's source directory ships whole to whoever can fetch it, so keep secrets out of the files under sourcePath.Declaring the loader releases a module supports
renderer names a framework, not a release of the loader that builds it: { name: "vue", version: "3" } is Vue 3 under every @antelopejs/dms-frontend release. The releases your module runs on are declared in its own package.json, the way a package declares the Node.js versions it supports:
{
"name": "@acme/dms-billing-frontend-vue",
"type": "module",
"main": "./dms.frontend.ts",
"engines": {
"@antelopejs/dms-frontend": ">=0.9.0 <0.10.0"
}
}
The loader reads it before it generates the workspace, in dev, build and prepare as in ajs dms verify-source, and stops with your module, its range and its own release when that release falls outside the range, instead of failing later on an API the release removed or does not have yet. A module that declares no range still loads, with a one-time warning.
Start the range at the lowest release the module is checked against, and cap it below the next breaking release, which is the next minor release while the loader is 0.x. When a new loader release ships, check the module against it, then widen the range in a release of the module. A range open to <1.0.0 admits the very release that removes something the module calls.
Localization
A module contributes translations by dropping JSON files under i18n/locales/. The loader discovers them without any declaration; the language code comes from the filename, which must end in -<ll>-<CC>.json (for example marketing-en-GB.json → en). Files sharing a language code merge across modules at build time, which is how every module contributes to the same language and why one namespace can receive keys from several modules. See Localization.
How a backend-described page renders
One dynamic page component renders every backend-defined page:
- The backend serves the page's route metadata and its serialized component tree.
- Each node's
componentNameis mapped to a real component withresolveDmsComponent(name) || "div", and the resolver recurses through the node's children, carrying each child'sslotandcolSpan. - The resolved tree is rendered top-level node by top-level node, and page realtime, permission refresh and page-setup functions are wired alongside it.
That is why a backend edit changes the UI: the page is data the frontend draws, not a compiled Vue route.
The materialized workspace is a physical copy
The ajs dms CLI, not the backend, materializes every resolved module as physical files under ~/.antelopejs/dms-frontend/<sha256(workspaceKey)>/frontend-modules/<safeName>/.... The workspace directory is the SHA-256 hash of the workspace key (the canonical backend URL, or project:<absolute project path> when dev auto-discovers the backend). The safeName is the module's package name with a leading @ stripped and every / replaced by __ (e.g. @antelopejs/my-module → antelopejs__my-module). Each copy is declared as a pnpm workspace package of the generated workspace, and the dev server serves from those copies, not from your module's frontend-vue/ source.
The copy is filtered by the module's .gitignore, so node_modules, build output and caches stay out of it. Nothing is symlinked, even in dev: pnpm resolves workspace packages through their real path, and a symlink would install the module's transitive dependencies back into your source tree.
In dev, ajs dms dev runs file watchers that continuously resync source edits into the materialized copy — adds, changes and deletes mirror in real time, not just on restart. So a .vue or composable edit normally propagates on its own. If a reload still doesn't show the change, suspect a stale copy.
This live-resync applies to every resolved frontend module, not just the ones you develop locally — inclusion itself is the AddFrontendModule call.
Read the registered frontend modules from the backend
A backend module can list the registered frontend modules with GetFrontendModules() from @antelopejs/interface-dms/page (name, source directory and public options). The list changes as modules register or are taken down, and modules start in parallel, so a module reading it in its own start() may run before another registers. Register a handler on FrontendModulesChanged first, read the list once, and read it again in the handler:
import { FrontendModulesChanged, GetFrontendModules } from "@antelopejs/interface-dms/page";
FrontendModulesChanged.register(() => void refresh());
await refresh(); // reads GetFrontendModules()