Module authoring

Crossing the boundary

The four channels that carry a module option from the build world into the browser — public and private runtime config, appConfig and generated templates — and how to choose between them.

Your module's setup receives a fully resolved options object. That object lives in Node, in a process that exits when the build finishes. Getting a value from there into a composable running in someone's browser is the single most-asked module question, because the answer forces you to reason about payload size, environment variables, tree-shaking and secrets all at once. There are exactly four channels, and the senior answer is the trade-off table, not the API call.

Know

  • The four channels, and nothing else: runtimeConfig.public.<key>, private runtimeConfig.<key>, appConfig, and a generated template imported as #build/<filename>.
ChannelAvailable whereEnv-overridable at runtimeIn the payload / bundleUse for
runtimeConfig.public.<configKey>server + clientYes — NUXT_PUBLIC_<KEY>_<OPTION>Serialised into every SSR responseEnvironment-specific non-secret values: API base URL, feature flags
runtimeConfig.<configKey> (private)server onlyYes — NUXT_<KEY>_<OPTION>Not sent to the clientSecrets, server-only endpoints, signing keys
appConfigserver + client, reactive, typedNo — build-time onlyBundled into the client chunkTheme and design tokens, UI defaults, anything that must not change per environment
addTemplate#build/…wherever you import itNo — build-time code generationBundled, and tree-shakeableDerived config, generated registries and maps, functions, large static data
  • Merge direction matters. Write your defaults under the user with defu: nuxt.options.runtimeConfig.public.toolkit = defu(nuxt.options.runtimeConfig.public.toolkit, { ... }). The consumer's own nuxt.config entry must keep winning, otherwise a module silently overwrites the value someone set deliberately.
  • Read runtime config with useRuntimeConfig(), never from module-scope. On the server it is per-request (it is read off the event), so hoisting it into a module-level const inside a runtime file freezes the first request's values and leaks across requests.
  • Environment variables are only picked up for keys that already exist. NUXT_PUBLIC_TOOLKIT_GREETING overrides runtimeConfig.public.toolkit.greeting only if that key was present at build time. A key you "forgot" to default is simply not overridable — which is why modules seed every option into runtime config even when the default is empty.
  • appConfig is reactive. useAppConfig() returns a reactive object and updateAppConfig() can patch it at runtime, which is why it suits theme tokens. It is still baked into the bundle, so it cannot differ between staging and production from the same artifact.
  • Templates are the escape hatch for size and shape. A generated module produces real JavaScript: the bundler tree-shakes what nobody imports, and nothing travels through the HTML payload. Anything large, derived, or that wants to be a function belongs here, not in runtime config.
  • Type augmentation belongs in nuxt/schema. Declare PublicRuntimeConfig, RuntimeConfig and AppConfigInput so consumers get completion. @nuxt/module-builder emits this for you from the interfaces you export (see Templates and types); knowing the manual form still matters because you will read it in other modules' dist/types.d.mts.
  • For a layers-first toolkit, the layer's nuxt.config is where the team's values live and the module is where the plumbing lives: the layer sets toolkit: { apiBase: … }, the module seeds it into runtime config so deploys can override it with NUXT_PUBLIC_TOOLKIT_API_BASE without a rebuild.

How it works

All four channels in one setup:

src/module.ts
import { addImports, addTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'
import { defu } from 'defu'

export interface ModuleOptions {
  apiBase: string
  apiToken: string
  theme: 'default' | 'compact'
  features: string[]
}

export default defineNuxtModule<ModuleOptions>({
  meta: { name: 'nuxt-team-toolkit', configKey: 'toolkit' },
  defaults: { apiBase: 'https://api.internal', apiToken: '', theme: 'default', features: [] },
  setup (options, nuxt) {
    const { resolve } = createResolver(import.meta.url)

    // 1. public runtime config — reaches the browser, overridable with NUXT_PUBLIC_TOOLKIT_API_BASE
    nuxt.options.runtimeConfig.public.toolkit = defu(
      nuxt.options.runtimeConfig.public.toolkit as Partial<ModuleOptions>,
      { apiBase: options.apiBase },
    )

    // 2. private runtime config — server only, overridable with NUXT_TOOLKIT_API_TOKEN
    nuxt.options.runtimeConfig.toolkit = defu(
      nuxt.options.runtimeConfig.toolkit as Partial<ModuleOptions>,
      { apiToken: options.apiToken },
    )

    // 3. appConfig — reactive, bundled, same in every environment
    nuxt.options.appConfig.toolkit = defu(nuxt.options.appConfig.toolkit, { theme: options.theme })

    // 4. template — build-time code generation, tree-shakeable, never in the payload
    addTemplate({
      filename: 'toolkit/features.mjs',
      getContents: () => [
        `export const features = ${JSON.stringify(options.features)}`,
        `export const hasFeature = name => features.includes(name)`,
      ].join('\n'),
    })

    addImports({ name: 'useToolkit', from: resolve('./runtime/app/composables/useToolkit') })
  },
})

The runtime side reads each channel with the right composable:

src/runtime/app/composables/useToolkit.ts
import { hasFeature } from '#build/toolkit/features.mjs'
import { useAppConfig, useRuntimeConfig } from '#imports'

export function useToolkit () {
  // per request on the server: must be called inside setup / a composable, never hoisted
  const { apiBase } = useRuntimeConfig().public.toolkit
  const { theme } = useAppConfig().toolkit
  return { apiBase, theme, hasFeature }
}

Types so consumers get completion in nuxt.config and in their components:

src/module.ts
declare module 'nuxt/schema' {
  interface RuntimeConfig { toolkit: { apiToken: string } }
  interface PublicRuntimeConfig { toolkit: { apiBase: string } }
  interface AppConfigInput { toolkit?: { theme?: 'default' | 'compact' } }
}
Gotcha· Everything public is in every response

runtimeConfig.public is serialised into the #__NUXT_DATA__ script of every SSR response, uncompressed in your own accounting and paid for on every page load. A 200 KB generated route map put there adds 200 KB to each document. Keep public runtime config to a handful of scalars; move anything derived or large into a template, where the bundler tree-shakes it and it is cached as JS.

Gotcha· Env overrides are coerced to the default's type

Values from NUXT_* variables arrive as strings and are parsed with destr against the type of the existing default. A default of true makes NUXT_PUBLIC_TOOLKIT_ENABLED=false a boolean; a default of '' or a missing key leaves you with the string "false", which is truthy. Seed every option with a default of the right type, and never with undefined.

Gotcha· Reading nuxt.options from runtime code

nuxt.options exists only in the build process. Importing useNuxt() or referencing nuxt.options from src/runtime/** either fails to resolve in the bundle or throws "Nuxt instance is unavailable". If a runtime file needs a build-time value, pass it through one of the four channels — that is the whole point of the table.

Verify before the interview:

The exact env-variable naming rules (casing, how nested keys are flattened, which types are coerced) are worth re-reading the week before, along with whether updateRuntimeConfig from kit is still the recommended way to write into it from a module.

docs ↗

Exercise

Exercise
  • Implement all four channels in nuxt-team-toolkit. Run pnpm dev:prepare then build the playground, start node playground/.output/server/index.mjs, curl a page and find the public runtime config in the HTML payload. Grep .output/public/_nuxt for your template's contents and confirm the private token appears in neither.
  • Set NUXT_PUBLIC_TOOLKIT_API_BASE=https://staging.internal and restart the built server without rebuilding. Confirm the override. Try the same trick on the appConfig theme and confirm it does nothing.
  • Put a 200 KB JSON blob in runtimeConfig.public, measure the document size with curl -so /dev/null -w '%{size_download}', then move it into a template and measure again.

Be able to say

Be able to say· Your module takes an option. How does a composable in the browser read it?

"There are four channels and I pick by three questions: does it differ per environment, is it secret, and how big is it. Public runtime config reaches both sides and can be overridden at deploy time with a NUXT_PUBLIC_ variable, but it is serialised into every SSR response, so I keep it to a few scalars. Private runtime config stays server-side for tokens. appConfig is reactive and typed but baked into the bundle, so it is right for theme tokens and wrong for anything environment-specific. Anything large or derived goes through addTemplate and is imported from #build/…, because that produces real code the bundler tree-shakes and it never touches the payload. I always merge my defaults under the user's config with defu so their nuxt.config still wins, and I never read nuxt.options from runtime code — it does not exist there."