Nuxt Layers

Server code and Nitro in layers

What a layer can ship under server/ and shared/, how Nitro merges handlers and config across layers, and why every route you ship is a security decision for every app.

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.

Know

  • Scanned per layer: 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.
  • Server secrets come from the app, never the layer. A layer declares 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.
  • Namespace what you ship. /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.
  • Per-request state goes on 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.
  • Nitro version: Nuxt 4 runs Nitro 2 with h3 v1 (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

How it works

A health endpoint every app gets, reading a layer-declared runtime value:

layers/base/server/api/_team/health.get.ts
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
  }
})
layers/base/nuxt.config.ts
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:

layers/base/server/plugins/request-id.ts
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:

layers/base/shared/types/team.ts
export interface TeamHealth {
  ok: boolean
  release: string
}
app/pages/status.vue
<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>
Gotcha· Your debug endpoint is now in twenty production apps

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.

Gotcha· Middleware from every layer runs on every request

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Add the health handler, the request-id plugin and the shared type to a layer. From the playground, curl -i /api/_team/health and check the x-request-id header and the JSON.
  • Set 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.
  • Add server/api/_team/health.get.ts to the project too, returning { ok: false }. Confirm the project's handler wins.

Be able to say

Be able to say· What server-side code belongs in a layer, and what do you watch out for?

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

Your notes

Nothing yet. Notes filed here show up on this page and the section overview.