Nuxt Layers

Paths and aliases inside a layer

Why ~ and @ break inside a layer, the import.meta.url pattern, the

Most "the layer works in its playground but not in my app" reports come down to one sentence from the docs: global aliases such as ~/ and @/ "are resolved relative to the user's project paths". A layer is merged into somebody else's project, so every path it writes must be anchored to itself. This page is the habit that prevents the whole class of bugs.

Know

  • ~, @, ~~, @@ point at the consuming project, both in nuxt.config.ts and inside a layer's runtime code. In the layer's own playground they happen to resolve to the right place, which is why the bug only appears at the first consumer.
  • In nuxt.config.ts, anchor to the file:
    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({
      css: [join(currentDir, './app/assets/css/main.css')],
      nitro: { storage: { cache: { driver: 'fs', base: join(currentDir, '.cache') } } },
    })
    

    This exact pattern is what layers/base/nuxt.config.ts in this repository does.
  • In runtime code, prefer relative imports or auto-imports. A layer component importing ~/composables/useTeamAuth looks for it in the project. Use .//../ paths, rely on auto-imports (the layer's app/composables is scanned anyway), or use the named alias.
  • #layers/<name> is the by-path escape hatch. Auto-scanned layers are named after their folder; other layers need $meta: { name }. import { formatDuration } from '#layers/base/app/utils/format' resolves regardless of where the consuming project lives, and it is how a project can wrap a layer component it overrides instead of copying it.
  • Only the project's .env is loaded. c12 reads .env from the project root during nuxt dev/build; a .env inside a layer is ignored, and no .env is read by the built server at all. Layer runtimeConfig defaults must therefore be non-secret literal values, with each app supplying NUXT_* variables in its own environment.
  • Tailwind v4 scans from the project. Automatic source detection starts at the project root and skips node_modules, so classes used inside a layer under layers/ are found, while a layer consumed from npm is invisible until the CSS entry says @source "../../../node_modules/@team/nuxt-layer-base"; (three levels up from app/assets/css/main.css to the project root).
  • TypeScript follows the same rule. Nuxt generates .nuxt/tsconfig.*.json with paths for every layer, so layer code type-checks inside any consumer and inside the playground; a layer package's own tsconfig.json should just extends the playground's generated one.

How it works

The wrap-and-override pattern, which needs the alias:

app/components/Team/Button.vue
<script setup lang="ts">
// The project overrides the layer's TeamButton by path, but still renders the original inside.
import BaseTeamButton from '#layers/base/app/components/Team/Button.vue'
</script>

<template>
  <BaseTeamButton v-bind="$attrs" class="tracking-wide">
    <slot />
  </BaseTeamButton>
</template>

In a module that ships with the layer, use kit's resolver instead of hand-rolled URL code:

layers/base/modules/team-devtools.ts
import { addPlugin, createResolver, defineNuxtModule } from 'nuxt/kit'

export default defineNuxtModule({
  meta: { name: 'team-devtools' },
  setup() {
    const { resolve } = createResolver(import.meta.url)
    addPlugin(resolve('./runtime/plugin.client'))   // anchored to the module file, not the project
  },
})
Gotcha· It works in the playground

The playground lives inside the layer repository, so ~/assets/css/main.css resolves to the layer's own app/assets. Consumers are elsewhere. Add a CI job that builds a fixture outside the layer folder (or a consumer in another workspace package) so alias mistakes fail before release; see Testing and CI.

Gotcha· Aliases in CSS too

@import "~/assets/css/tokens.css" inside a layer stylesheet has the same problem. Use a relative import (@import "./tokens.css") inside the layer's CSS.

Verify before the interview:

The minimum Nuxt version for the #layers/<name> alias (documented as 3.16) and whether $meta.name is required for layers listed in extends.

docs ↗

Exercise

Exercise
  • In a layer, write css: ['~/assets/css/main.css'], extend it from an app in a different directory and read the error. Fix it with import.meta.url.
  • Override a layer component in the project and, inside the override, render the original through #layers/<name>/app/components/.... Confirm both the layer's markup and your wrapper appear.
  • Put a .env with NUXT_PUBLIC_API_BASE inside the layer, then in the project. Log useRuntimeConfig().public.apiBase in both cases.

Be able to say

Be able to say· What is the most common bug in a layer, and how do you prevent it?

"Paths. Aliases like ~ and @ resolve against the consuming project, so a layer that references its own CSS or assets through them works in its playground and breaks in the first real app. Inside the layer's nuxt.config I anchor everything to import.meta.url; in runtime code I use relative imports, auto-imports, or the #layers/<name> alias when a project needs to import a layer file by path. The same rule explains two neighbours: only the project's .env is loaded, and Tailwind only scans the project tree, so an npm-installed layer needs an explicit @source."