Nuxt Layers

Overriding what a layer ships

How a project replaces, wraps, extends or removes components, pages, layouts, composables, plugins, middleware and server routes inherited from a layer.

A layer is only useful if consumers can bend it. The override model in Nuxt is deliberately dumb: the same resolved name at a higher priority wins. There is no override: keyword, no registry, no merge of two components. That simplicity is what makes it predictable, and it also produces the sharp edges this page catalogues: what "same name" means for each file type, what cannot be overridden by placing a file, and how to extend instead of replace.

Know

  • Components are matched by their resolved component name (path-prefixed by default). A project app/components/Team/Button.vue replaces the layer's app/components/Team/Button.vue because both resolve to TeamButton. A project file with a different path but the same final name also wins, with a warning about the duplicate.
  • Pages are matched by route. A project app/pages/settings.vue replaces the layer's settings route; pages the project does not redefine stay. To remove an inherited page use the pages:extend hook and filter the routes.
  • Layouts are matched by name (default.vue). Middleware by file name for named middleware; global middleware (*.global.ts) from every layer runs.
  • Composables and utils are auto-imports matched by exported name. Same file path → override. Different files exporting the same name → the higher-priority layer wins the import and Nuxt logs a duplicate-import warning; do not rely on it, rename or prefix.
  • Plugins with the same relative path are overridden; otherwise plugins from all layers run. Ordering is by file name across the merged list, so a layer's 01.team-setup.ts runs before the project's 02.tracking.ts; use dependsOn in the object syntax when order matters rather than trusting names.
  • Server handlers in server/api and server/routes are matched by path; server/middleware from every layer runs on every request. Namespace layer routes (/api/_team/...) so an app cannot collide by accident.
  • app.vue, error.vue, app/router.options.ts come from the project if present, otherwise from the highest-priority layer that has one. A layer app.vue is a legitimate way to ship a default shell with <NuxtLayout> and <NuxtPage>.
  • Wrap instead of replace with the #layers/<name> alias: the project component imports the layer's original by path and renders it inside, adding props, classes or slots. This keeps the layer's future fixes flowing into the app.

How it works

Three ways to change a layer component, in order of preference:

app/app.config.ts
// 1. Configure: the layer exposes knobs in app.config; the project sets them. No override at all.
export default defineAppConfig({
  team: { button: { rounded: 'full', size: 'lg' } },
})
app/components/Team/Button.vue
<script setup lang="ts">
// 2. Wrap: replace the registration but keep rendering the original.
import LayerButton from '#layers/base/app/components/Team/Button.vue'
</script>

<template>
  <LayerButton v-bind="$attrs" data-analytics="cta">
    <slot />
  </LayerButton>
</template>
app/components/Team/Button.vue
<!-- 3. Replace: same path, brand-new implementation. The layer's version is no longer registered. -->
<template>
  <button class="btn"><slot /></button>
</template>

Removing an inherited route, which no file placement can do:

nuxt.config.ts
export default defineNuxtConfig({
  hooks: {
    'pages:extend'(pages) {
      const drop = new Set(['/legacy-dashboard'])
      for (let i = pages.length - 1; i >= 0; i--) {
        if (drop.has(pages[i]!.path)) pages.splice(i, 1)
      }
    },
  },
})

Proving which file won, from a local module, without guessing:

modules/who-wins.ts
import { defineNuxtModule } from 'nuxt/kit'

export default defineNuxtModule({
  setup(_options, nuxt) {
    nuxt.hook('components:extend', (components) => {
      const hit = components.find(c => c.pascalName === 'TeamButton')
      console.log('TeamButton resolves to', hit?.filePath)
    })
  },
})
Gotcha· Overriding a page does not override its data

Replacing app/pages/settings.vue replaces the view. If the layer's page relied on a layer middleware or a layer composable, those still exist and still run where they are referenced. Read the layer page you are replacing and decide deliberately what to keep.

Gotcha· Two plugins, one job

The classic duplicate: the layer ships app/plugins/analytics.client.ts and the app already has app/plugins/tracking.client.ts initialising the same SDK. Different names, both run, two SDK instances. Layers should make plugins switchable through app.config or a module option, and document which plugins they add.

Verify before the interview:

Exact plugin ordering across layers (whether the merged list is sorted by file name globally or per layer). Test with two numbered plugins in a layer and the project before quoting a rule.

docs ↗

Exercise

Exercise
  • Ship TeamButton from a layer. Override it in the project three ways (app.config knob, wrapper via #layers/..., full replacement) and confirm each with the components:extend logger above.
  • Add app/pages/legacy-dashboard.vue to the layer, then remove it from the project with pages:extend. Confirm /legacy-dashboard 404s while other layer pages still resolve.
  • Put 01.a.ts in the layer and 00.b.ts in the project under app/plugins/, log from each, and write down the order you observed.

Be able to say

Be able to say· How does an app customise a component that comes from your layer?

"Three levels. First, configuration: the layer exposes knobs in app.config, the app sets them and nothing is overridden. Second, wrapping: the app creates a component at the same path, imports the original through the #layers/base/... alias and renders it with extra props or classes, so it keeps receiving the layer's fixes. Third, replacement: same path, new implementation, and the layer's version is simply not registered anymore. Same-name-wins is the whole rule, which is why I namespace everything the layer ships and document the override recipe in its README."