Layered architecture
Layered architecture
The admin uses Nuxt 4 layers to compose a project admin on top of the shared base. Merging happens at four levels — pages, components, composables, and plugins all auto-discover across every layer.
Directory shape
your-monorepo/
├── js-admin-nuxt4/ # base layer (shared, cloned as-is)
│ ├── layouts/dashboard.vue
│ ├── composables/useApiFetch.ts
│ ├── components/ResourceTable.vue
│ ├── modules/
│ │ ├── auth/ users/ settings/ flows/ ai/ …
│ │ └── discovery.ts # scans sibling + layered modules
│ ├── pages/index.vue
│ └── nuxt.config.ts
└── your-admin/ # project layer (yours)
├── assets/css/tailwind.css
├── modules/
│ └── your-module/
├── app.config.ts # brand colours
└── nuxt.config.ts # extends: [../js-admin-nuxt4]
The three gotchas
1. ~ doesn't resolve to the base layer
In Nuxt layers, ~/some/path resolves to the consuming project's root at vite-node runtime, not the layer's root. Base-layer code that writes ~/composables/useApiFetch will 404 at runtime in a layered build.
Fix: rely on Nuxt's auto-import. Composables from any layer are auto-imported; you rarely need an explicit import line. When you do, use relative paths or the @redelay alias (set below).
2. Project must own assets/css/tailwind.css
The base layer's nuxt.config.ts declares css: ['~/assets/css/tailwind.css']. When extended, ~ points at the project — so every project must ship its own copy. Don't @import the base's CSS; just duplicate the @import "tailwindcss" + @import "@nuxt/ui" lines and add @source globs covering both trees.
The base layer's CSS entry is gated on NUXT_STANDALONE so admin-core (running the base alone) still gets styled.
// js-admin-nuxt4/nuxt.config.ts
css: process.env.NUXT_STANDALONE
? [resolve(__dirname, './assets/css/tailwind.css')]
: [],
3. pnpm + layers = duplicate pinia/vue instances
pnpm installs into strict per-package .pnpm/ trees. If both layers install pinia, each gets its own instance and useStore() will throw getActivePinia() errors. Keep the base layer's node_modules out of the layered runtime — only the project installs dependencies; the base is just source files.
Project nuxt.config.ts skeleton
A project depends on the base layer as a pinned, versioned dependency (Mode
B) — the npm package @redelay/js-admin, resolved from the
redelay/js-admin-nuxt4 repo at a tag — with a gitignored pnpm-workspace.yaml
override that links a sibling checkout for local dev. The full recipe (pinning,
the override, Docker, credentials, bumping) is
Consuming the base layer — use that for every
new project. The skeleton below shows only the nuxt.config mechanics.
import { realpathSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
// Extend by the resolved node_modules PATH — not a bare '@redelay/js-admin'
// specifier, not the sibling tree. realpathSync resolves the pnpm symlink so
// the layer's own ~/config aliases resolve. See the three gotchas above.
const baseLayer = realpathSync(resolve(__dirname, 'node_modules/@redelay/js-admin'))
export default defineNuxtConfig({
extends: [baseLayer],
compatibilityDate: '2024-11-01',
alias: {
'@redelay': baseLayer,
},
css: [resolve(__dirname, './assets/css/tailwind.css')],
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_API_URL || 'http://localhost:8888',
// Only if your backend moved auth off the /auth default (AUTH_ROUTE_PREFIX).
// Leave unset to inherit the base layer's default of '/auth'.
// authPrefix: '/login',
},
},
routeRules: {
'/api/v1/**': {
proxy: `${process.env.NUXT_API_INTERNAL_URL || process.env.NUXT_API_URL || 'http://backend:8888'}/api/v1/**`,
},
},
})
Tailwind @source globs follow the same rule — point them at
node_modules/@redelay/js-admin/**, not the sibling tree, so a versioned/CI
build (which has no sibling checkout) still has files to scan. See
Consuming the base layer.
The base layer proxies /api/v1/** server-side to admin-api, so the browser
talks only to the admin origin — no CORS, and admin-api's port need not be
public. If the backend relocated auth with AUTH_ROUTE_PREFIX, set
authPrefix (or NUXT_PUBLIC_AUTH_PREFIX) to the same value; see the
auth-prefix contract.
Page discovery
js-admin-nuxt4/modules/discovery.ts hooks into Nuxt's pages:extend and components:dirs. It walks nuxt.options._layers — the list Nuxt builds from the project itself plus every extends: target — and scans each layer's modules/<name>/pages/ and modules/<name>/components/ directories.
Because the base layer is an extends: target, it's discovered automatically — no env var or extra configuration. (An earlier implementation used a NUXT_CORE_LAYER_PATH env var; that is gone. If you see it in an old compose file, delete it.) This is why extending by the resolved node_modules/@redelay/js-admin path (see Consuming the base layer) is all discovery needs: that path is already in _layers.
Page paths
A file at modules/<mod>/pages/<p1>/<p2>.vue renders at route /<p1>/<p2>. The outer <mod> segment is just an organisational prefix; it disappears from the URL.
modules/users/pages/users/index.vue→/usersmodules/users/pages/users/[id].vue→/users/:idmodules/flows/pages/flows/deployments/[id].vue→/flows/deployments/:id