Under the hood

Layers vs modules

The decision rule in one sentence, why a company toolkit is almost always both, and where the deep treatment lives.

Every internals conversation ends here, because the two mechanisms overlap just enough to make the choice feel arbitrary. It is not. This page is the one-minute version — the Layers section covers priority, merging, aliases, publishing and testing in depth.

Know

  • The decision rule. Layers ship app code and conventions — components, pages, composables, server routes, config presets, app.config. Modules ship build-time behaviour and injected runtime — generated templates, transforms, registered handlers, types, reactions to the consumer's config.
  • The hard boundary is execution. A layer is declarative: c12 merges its nuxt.config into yours and Nuxt resolves its directories. It cannot run anything at build time. The moment your toolkit needs to inspect the consumer's config, generate a registry, validate options or add a Vite transform, that logic has to be a module.
  • A company toolkit is usually both: a layer for the scaffold (directories, CSS, app.config, sensible defaults) that lists its own module in modules for the machinery. Consumers get everything from one extends entry.
  • The combination is safe because of two merge rules. modules arrays concatenate under defu and Nuxt deduplicates by meta.name, so the layer and the project can both list the module and it runs once; and module options resolve as defu(inline, nuxt.config[configKey], defaults), so the project beats the layer beats the defaults.

How it works

layers/toolkit/nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@acme/toolkit'], // the layer ships the machinery with the scaffold
  css: [join(currentDir, './app/assets/toolkit.css')],
  appConfig: { toolkit: { density: 'comfortable' } },
})
nuxt.config.ts
export default defineNuxtConfig({
  extends: ['@acme/toolkit-layer'],
  modules: ['@acme/toolkit'], // listing it again is harmless: deduplicated by meta.name
  toolkit: { density: 'compact' }, // the project's options beat the layer's
})
Gotcha· Shipping as a layer because it is easier, then needing a module

Layers are quicker to start, so teams ship the scaffold first and bolt build logic on later as a modules/ file inside the layer. It works, but that is now an unversioned module consumers cannot pin, configure or disable independently. If there is any chance you will need build-time behaviour, publish the module as its own package from day one and let the layer depend on it.

Exercise

Exercise
  • Create playground-layer/ that lists your module in modules, overrides one of its components and sets an app.config value. Extend it from a playground that also lists the module, and confirm the module's setup runs exactly once.
  • Set the same module option in the layer's config and in the project's config; confirm which wins and write the rule down.

Be able to say

Be able to say· Layers or a module — how do you decide?

"Layers ship app code and conventions; modules ship build-time behaviour and the runtime they inject. The test I use is whether anything needs to execute while Nuxt starts: a layer is declarative, so it can give you components, pages, server routes and merged config, but it cannot inspect the consumer's setup, generate a registry or add a transform. The moment I need that, it is a module. In practice a company toolkit is both — a layer for the scaffold that lists its own module in modules, so the team gets everything from one extends entry. That is safe because modules arrays concatenate and Nuxt deduplicates by module name, and because module options resolve with the project's config beating the layer's beating my defaults."