[data-reveal]{opacity:1!important;transform:none!important}
Frontend layer
Building

Frontend layer

Author a custom Vue frontend module and understand the machine that loads it — the dms.frontend.ts entry, component registration, overrides, configuration, localization, the materialized workspace, and the catch-all renderer.

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.

The loader's own reference — every file a frontend module may ship (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/
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.

A large module may split those convention-discovered directories into sub-roots under 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).

frontend-vue/dms.frontend.ts
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:

MemberWhat it does
optionsThe 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.

frontend-vue/app/components/ExampleView.vue
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:

frontend-vue/dms.frontend.build.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/composables for 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. Import app/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-build and 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:

  1. Register the same full name, without your prefix. sdk.registerComponent("DmsFoo", MyFoo, { prefix: false }) replaces the DMS's DmsFoo; a registration made that way is not reported as a collision. A different name produces a different component, not an override.
  2. 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 at priority: 0; use priority: 1 to 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:

src/index.ts
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:

frontend-vue
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.

The manifest and the module source are served through the instance's 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:

frontend-vue/package.json
{
  "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:

  1. The backend serves the page's route metadata and its serialized component tree.
  2. Each node's componentName is mapped to a real component with resolveDmsComponent(name) || "div", and the resolver recurses through the node's children, carrying each child's slot and colSpan.
  3. 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()