Layers are usually discussed as a front-end concern, but a layer's server/ directory is scanned exactly like the project's. That makes a base layer the natural home for the cross-cutting server pieces a team keeps re-implementing: health and version endpoints, request-id and logging plugins, security headers, a typed client for the internal API. It also means every one of those pieces runs inside every consuming app, in production, which is why this page ends on security rather than starting on it.
server/api/** (mounted under /api), server/routes/** (no prefix), server/middleware/** (runs on every request), server/plugins/** (Nitro plugins, run once at server start), server/utils/** (auto-imported in server code), server/types/** (server-only types). Same handler path in two layers → the higher-priority layer wins; middleware and plugins from all layers run.shared/ (Nuxt ≥ 3.14) is scanned per layer too: shared/utils and shared/types are auto-imported in both the Vue app and Nitro, anything else is reachable via #shared. Shared code cannot import Vue or Nitro APIs, which is exactly the constraint you want for a layer's DTO types and pure helpers.nitro config merges with defu like the rest of nuxt.config: nitro.storage mounts and routeRules deep-merge (project wins per key), arrays such as nitro.prerender.routes or externals.inline concatenate. Paths in the layer's Nitro config must be resolved from import.meta.url.runtimeConfig.teamApiToken: '' so the key and type exist; each app supplies NUXT_TEAM_API_TOKEN in its environment. Read it with useRuntimeConfig(event) inside handlers so runtime overrides apply./api/_team/health cannot collide with an app's /api/health; server/utils/teamFetch.ts is less likely to shadow an app's helper than fetchJson.ts. Auto-imported utils share one namespace across layers.event.context (typed by augmenting H3EventContext), never in module scope: a layer's Nitro plugin runs once per server process and its handlers run for every app's traffic; see Memory leaks.defineEventHandler, getQuery, readValidatedBody). Nuxt 5 moves to Nitro v3, so layer server code should avoid deep nitropack imports and stick to the Nuxt-provided auto-imports. Nuxt 5 A health endpoint every app gets, reading a layer-declared runtime value:
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event) // env overrides (NUXT_PUBLIC_TEAM_RELEASE) apply at runtime
return {
ok: true,
release: config.public.teamRelease,
memory: import.meta.dev ? process.memoryUsage() : undefined, // never leak process details in production
}
})
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = dirname(fileURLToPath(import.meta.url))
export default defineNuxtConfig({
runtimeConfig: {
teamApiToken: '', // private; apps set NUXT_TEAM_API_TOKEN
public: { teamRelease: 'dev' }, // public; apps set NUXT_PUBLIC_TEAM_RELEASE
},
routeRules: {
'/api/_team/**': { cors: false, cache: false },
},
nitro: {
storage: { 'team-cache': { driver: 'fs', base: join(currentDir, '.data/team-cache') } },
},
})
A request-id plugin with typed per-request context:
declare module 'h3' {
interface H3EventContext {
requestId: string
}
}
export default defineNitroPlugin((nitroApp) => {
// Registered once at server start; the hook then fires per request.
nitroApp.hooks.hook('request', (event) => {
event.context.requestId = getRequestHeader(event, 'x-request-id') ?? crypto.randomUUID()
setResponseHeader(event, 'x-request-id', event.context.requestId)
})
})
A shared type both sides can use, shipped by the layer:
export interface TeamHealth {
ok: boolean
release: string
}
<script setup lang="ts">
// TeamHealth is auto-imported from the layer's shared/types in app code as well as server code.
const { data } = await useFetch<TeamHealth>('/api/_team/health')
</script>
process.memoryUsage(), environment dumps, "reset cache" routes: things that are harmless in a playground become an information-disclosure or DoS surface the moment a layer ships them. Guard with import.meta.dev, require an auth check, or move them behind a module option that defaults to off.
A layer's server/middleware/security-headers.ts is convenient until a second layer adds its own and two Content-Security-Policy headers collide. Keep one owner for cross-cutting middleware (the base layer) and make it configurable rather than letting feature layers add their own.
The order in which Nitro runs server/middleware files coming from several layers, and whether same-named middleware files override or both run. Test with two layers before quoting a rule.
curl -i /api/_team/health and check the x-request-id header and the JSON.NUXT_PUBLIC_TEAM_RELEASE=1.4.2 when starting the built server (node .output/server/index.mjs) and confirm the value changes without a rebuild.server/api/_team/health.get.ts to the project too, returning { ok: false }. Confirm the project's handler wins."The cross-cutting pieces every app needs and nobody should write twice: health and version endpoints, a request-id and logging plugin, security headers, shared DTO types in shared/, and typed clients for internal APIs. Nitro scans a layer's server/ like the project's, so handlers with the same path are overridden by priority while middleware and plugins from every layer run. I namespace routes under /api/_team, keep secrets out of the layer by declaring empty runtimeConfig keys that each app fills from the environment, and treat every shipped route as a production endpoint in twenty apps: no debug output, input validated, auth by default."
Nothing yet. Notes filed here show up on this page and the section overview.
Modules in layers
Layers as module presets, how Nuxt deduplicates modules across layers, how module options merge, and how module authors make their modules layer-aware.
Publishing and consuming a layer
The layer starter and its playground, npm packaging rules, workspace versus registry versus git tags, the dev experience of each, and what counts as a breaking change.