The previous page gave the rule (defu: deep-merge objects, concatenate arrays, higher priority wins on scalars). This page walks through the options that matter for a team preset and says what the rule means for each one, because "it deep-merges" is not an answer an interviewer accepts about runtimeConfig or modules.
modules — arrays concatenate, then Nuxt deduplicates modules by name, so a layer and a project can both list @nuxt/ui safely. Module options (ui: {…}, content: {…}) are plain objects and deep-merge: the layer sets defaults, the project overrides keys. To switch a layer's module off, set its config key to false in the project (image: false).runtimeConfig — deep-merged; the layer provides defaults, each app overrides in its nuxt.config or at runtime with NUXT_<KEY> / NUXT_PUBLIC_<KEY> environment variables. Env values are coerced to the type of the default, so a layer default of false lets NUXT_PUBLIC_TEAM_BETA=true work; a missing default leaves you with a string. Never put secrets in a layer default: the layer is in git and, for public, in every payload.app.config — app/app.config.ts from every layer is merged with the project having priority; the result is reactive (useAppConfig(), updateAppConfig()) and cannot be changed by environment variables. It is the right channel for theme tokens and UI defaults, which is exactly how layers/base/app/app.config.ts and app/app.config.ts cooperate in this repository (site.name is overridden by the project).routeRules — object keyed by route pattern, so the project overrides a pattern the layer also declares and inherits the rest. Useful for shipping /api/_team/** caching defaults.css, plugins, vite.plugins, vite.optimizeDeps.include, imports.dirs — arrays, therefore concatenated. A layer's CSS is added to the app's, never replaced.$development, $production, $env.<name> — c12 applies these overrides per config file before merging, so a layer can ship "in production, prerender these routes" without the project knowing.components — every layer's app/components is registered automatically. A layer that wants a prefix declares its directory explicitly with prefix: 'Team', resolving the path from import.meta.url.compatibilityDate / future — set in the project. A layer should not force these on consumers; declare the Nuxt range it supports in package.json instead.A realistic preset layer and a project that adjusts it:
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = dirname(fileURLToPath(import.meta.url))
export default defineNuxtConfig({
$meta: { name: 'base' },
modules: ['@nuxt/ui', '@nuxt/eslint', '@nuxt/image'],
css: [join(currentDir, 'app/assets/css/main.css')],
components: [{ path: join(currentDir, 'app/components'), prefix: 'Team' }],
runtimeConfig: {
teamApiToken: '', // private, server only, set per app via NUXT_TEAM_API_TOKEN
public: { apiBase: 'https://api.internal', flags: { beta: false } },
},
routeRules: { '/api/_team/**': { cache: { maxAge: 60 } } },
eslint: { config: { stylistic: true } },
$production: { routeRules: { '/': { prerender: true } } },
})
export default defineNuxtConfig({
modules: ['@nuxt/content'], // final list: content, ui, eslint, image
image: false, // this app does not want @nuxt/image
runtimeConfig: { public: { flags: { beta: true } } }, // apiBase inherited, beta overridden
routeRules: { '/api/_team/**': { cache: { maxAge: 5 } } }, // pattern overridden, others inherited
})
export default defineAppConfig({
team: { brand: 'Acme', radius: 'md' },
ui: { colors: { primary: 'emerald', neutral: 'zinc' } },
})
export default defineAppConfig({
team: { brand: 'Acme Labs' }, // radius inherited; deep merge, project wins
})
Typing the layer's app.config surface so consumers get autocomplete and errors on typos:
declare module 'nuxt/schema' {
interface AppConfigInput {
team?: { brand?: string, radius?: 'sm' | 'md' | 'lg' }
}
}
export {}
If the layer adds a global stylesheet or a plugin you do not want, no config in the project will subtract it: arrays only grow. The layer must make it optional (behind a module option or an app.config flag), or you fork the layer. Design layers so that everything with a cost is opt-in or switchable.
app.config can be updated at runtime, but it is bundled: the same value ships to staging and production. Anything that differs per deployment belongs in runtimeConfig with an env override. The deciding question is not "does it change?" but "does it change per environment?"
Whether hooks declared in several layers' nuxt.config are all registered or merged as a single object (functions do not deep-merge). Test it in a playground before stating it.
nuxt.options.modules and nuxt.options.routeRules from a local module and check the concatenation and the override.NUXT_PUBLIC_FLAGS_BETA=false in the app's environment and confirm it overrides the project's true at runtime without a rebuild.team.radius from the project's .env. Explain, in one sentence, why nothing happens."All three deep-merge with the project on top, so a layer supplies defaults and an app changes individual keys. They differ in when they can change: runtimeConfig is overridable per deployment with NUXT_* variables, so it carries per-environment values and, in its private part, secrets; app.config is bundled and reactive, so it carries theme and UI defaults; module options are consumed once at build time by the module's setup. Arrays such as modules and css concatenate rather than override, which is why a layer should make anything costly optional instead of assuming an app can remove it."