Admin

Layered architecture

How the base layer and your project admin combine, plus the three gotchas that broke everyone before you.

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

text
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.

ts
// 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.

ts
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 → /users
  • modules/users/pages/users/[id].vue → /users/:id
  • modules/flows/pages/flows/deployments/[id].vue → /flows/deployments/:id