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.
runtimeConfig.public.<key>, private runtimeConfig.<key>, appConfig, and a generated template imported as #build/<filename>.| Channel | Available where | Env-overridable at runtime | In the payload / bundle | Use for |
|---|---|---|---|---|
runtimeConfig.public.<configKey> | server + client | Yes — NUXT_PUBLIC_<KEY>_<OPTION> | Serialised into every SSR response | Environment-specific non-secret values: API base URL, feature flags |
runtimeConfig.<configKey> (private) | server only | Yes — NUXT_<KEY>_<OPTION> | Not sent to the client | Secrets, server-only endpoints, signing keys |
appConfig | server + client, reactive, typed | No — build-time only | Bundled into the client chunk | Theme and design tokens, UI defaults, anything that must not change per environment |
addTemplate → #build/… | wherever you import it | No — build-time code generation | Bundled, and tree-shakeable | Derived config, generated registries and maps, functions, large static data |
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.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.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.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.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.All four channels in one setup:
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:
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:
declare module 'nuxt/schema' {
interface RuntimeConfig { toolkit: { apiToken: string } }
interface PublicRuntimeConfig { toolkit: { apiBase: string } }
interface AppConfigInput { toolkit?: { theme?: 'default' | 'compact' } }
}
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.
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.
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.
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.
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.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.runtimeConfig.public, measure the document size with curl -so /dev/null -w '%{size_download}', then move it into a template and measure again."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."
The kit API, grouped
The @nuxt/kit helpers a module author should know from memory, grouped by what they do, with what each one does underneath and the source files worth reading.
Rules of the runtime directory
What src/runtime may and may not import, how module-builder transpiles it file by file, the .client/.server suffixes, and how to make Nitro inline your server runtime so