Nuxt Layers

Designing a team layer

The senior-craft page for layers - defining a small public surface, theming through app.config, override recipes, performance and security costs per app, governance, and rolling out breaking changes.

Everything before this page was mechanism. This page is judgement: what makes a layer something twenty apps want to extend, keep extending after the third breaking change, and can safely be owned by one small platform team. The recurring theme is that a layer is an API whose surface is files, so every file you ship is a promise.

Know

  • Define the public surface and keep it small. It consists of: component names, composable and util names, app.config keys, runtimeConfig keys, page routes and layouts, server route paths, and the modules in the preset. Anything else is internal and should be marked as such (an _internal/ component folder, pathPrefix to hide it, no README mention).
  • Prefix everything. TeamButton, useTeamAuth, /api/_team/health, team.* in app.config, NUXT_TEAM_* in the environment. Prefixes prevent collisions with apps and with other layers, and make grep-based adoption tracking possible.
  • app.config is the theme API. Tokens and UI defaults live there, typed through AppConfigInput, reactive, overridable per app by placing a file. Anything per-environment goes to runtimeConfig instead (why).
  • Ship override recipes, not just components. The README states, per public component, how to configure it, how to wrap it via #layers/base/..., and what breaks if you replace it (patterns). Overrides you did not anticipate become support tickets.
  • Layering strategy: base (preset + design system + cross-cutting server pieces) → optional feature layers (auth, analytics, cms) → apps. Feature layers must not extend each other; apps compose them. The base layer changes least and is reviewed hardest.
  • Cost per app. A layer taxes every consumer: global components land in the entry chunk, every plugin runs on every SSR request and page load, CSS and fonts ship to everyone. Measure the bundle and TTFB delta of an empty app with and without the layer, budget it in CI, and make anything heavy opt-in (Performance).
  • Security per app. Server routes, middleware and Nitro plugins you ship run in production in every app. No debug endpoints, input validated, auth by default, secrets only through runtimeConfig keys the app fills, dependencies watched by Renovate and advisories (Senior craft).
  • Governance. One owning team, an RFC (one page) for any change to the base layer's public surface, a review checklist, a CHANGELOG with migration notes, adoption tracking (which app is on which version), a support window for the previous major.
  • Breaking changes are rolled out, not shipped: deprecate first (old name kept as a thin wrapper that warns in dev), provide a codemod when a rename touches many files, publish the migration guide, watch adoption, remove in the next major.

How it works

The theme API, typed:

layers/base/app/app.config.ts
export default defineAppConfig({
  team: {
    brand: 'Acme',
    density: 'comfortable',
    analytics: { enabled: false },
  },
  ui: { colors: { primary: 'emerald', neutral: 'zinc' } },
})
layers/base/app/types/app-config.d.ts
declare module 'nuxt/schema' {
  interface AppConfigInput {
    team?: {
      brand?: string
      density?: 'compact' | 'comfortable'
      analytics?: { enabled?: boolean }
    }
  }
}
export {}

A deprecation wrapper that keeps the old name working for one major:

layers/base/app/components/Team/PrimaryButton.vue
<script setup lang="ts">
// Deprecated in 2.x, removed in 3.0. Use <TeamButton variant="primary">.
import TeamButton from './Button.vue'

if (import.meta.dev) {
  console.warn('[nuxt-layer-base] <TeamPrimaryButton> is deprecated; use <TeamButton variant="primary">. See MIGRATION.md#2-1.')
}
</script>

<template>
  <TeamButton variant="primary" v-bind="$attrs">
    <slot />
  </TeamButton>
</template>

Budgeting the layer's cost in the layer's own CI:

# Build an empty fixture with and without the layer and compare the client entry size and a cold TTFB.
pnpm nuxt build test/fixtures/empty && du -sh test/fixtures/empty/.output/public/_nuxt
pnpm nuxt build test/fixtures/basic && du -sh test/fixtures/basic/.output/public/_nuxt
hyperfine --warmup 3 'curl -s http://localhost:3000/ -o /dev/null'

The one-page RFC every base-layer change goes through:

docs/rfc-template.md
# RFC: <change>
**Problem** — what apps cannot do today, with the two apps that asked.
**Proposal** — the public surface after the change (component/composable/app.config keys), with the override recipe.
**Compatibility** — breaking? deprecation path, codemod, migration note, target major.
**Cost** — bundle/TTFB delta measured on the empty fixture; new dependencies.
**Security** — new server routes/middleware and how they are guarded.
**Rollout** — canary app, adoption tracking, support window for the previous major.
Gotcha· A layer is not the place for one app's needs

The fastest way to rot a base layer is to accept "just add a prop for our app". Every consumer pays for it forever. Push app-specific needs to the app (override or wrap), or to a feature layer that only that family of apps extends.

Gotcha· Deprecation without adoption data is a guess

If you cannot say which apps still use <TeamPrimaryButton>, you cannot remove it. Track versions from the lockfiles in CI (a script that reads each app's pnpm-lock.yaml) or from a runtime header the layer's health route reports, before the first deprecation.

Verify before the interview:

The current recommended way to type app.config from a layer (AppConfigInput augmentation in nuxt/schema) and any notes on merging strategies for arrays in app.config.

docs ↗

Exercise

Exercise
  • Write the README of @team/nuxt-layer-base: what it ships (tables of components, composables, app.config keys, runtimeConfig keys, routes), install, the three override recipes, troubleshooting (from the pitfalls table), versioning policy.
  • Write the RFC for adding TeamDataTable using the template above, including a measured bundle delta.
  • Rename one public component with the deprecation wrapper pattern, bump the minor, and write the CHANGELOG entry and the migration note as consumers would read them.

Be able to say

Be able to say· How would you design the base layer for a team of twenty apps?

"I would start by writing down its public surface: prefixed components and composables, typed app.config keys as the theme API, runtimeConfig keys for per-environment values, namespaced server routes, and the module preset. Everything else is internal. Apps customise by configuring first, wrapping second, replacing last, and the README shows all three. I measure what the layer costs an empty app in bundle size and TTFB and budget it in CI, treat every shipped server route as production code in twenty apps, and put the base layer under one owning team with a one-page RFC for surface changes. Breaking changes go deprecate → warn → codemod → remove across a major, with adoption tracked so removal is a decision, not a guess."