Two different surfaces, one principle: make the common case require no configuration, make the uncommon case possible without a fork, and make everything else explicitly private.
analytics: { enabled, sampleRate } beats analyticsEnabled plus analyticsSampleRate. Every option typed in ModuleOptions, defaulted in defaults, documented in a table in the README.configKey, so the consumer knows where to edit.enabled: boolean to switch the module off without removing it, configKey in camelCase matching the package name, runtime APIs prefixed consistently (useToolkit*, <Toolkit*>).toolkit:extend) so other modules can contribute, a runtime hook (toolkit:ready) so apps can react, an alias (#toolkit/...) for advanced imports, and an overrides option for anything you compute.meta.compatibility.nuxt), with a version matrix in the README and CI running against nightly.useLogger('toolkit'), a debug option that prints your setup timings, and a DevTools tab when the module has state worth inspecting.A layer has no options object, so its surface is files and keys:
| Surface | Example | How a consumer changes it |
|---|---|---|
| Component names | <TeamButton> | override by path, or wrap through #layers/base/... |
| Composables and utils | useTeamAuth() | override by name, or call and wrap |
app.config keys | team.density | set the key in the app's app.config.ts |
runtimeConfig keys | NUXT_TEAM_API_TOKEN | environment variable per deployment |
| Routes and layouts | /account/**, layouts/team.vue | same-path override, or pages:extend to remove |
| Preset modules | @nuxt/ui, @nuxt/eslint | ui: false to disable, options to adjust |
app.config is the theme API, typed with an AppConfigInput augmentation, because it is merged, reactive and overridable by placing a file. Per-environment values belong in runtimeConfig instead.pathPrefix: false and an underscore-prefixed name, or simply are not registered at all.Validating a module's options so the failure is actionable:
import { defineNuxtModule, useLogger } from '@nuxt/kit'
export interface ModuleOptions {
enabled: boolean
apiBase: string
analytics: { enabled: boolean, sampleRate: number }
}
export default defineNuxtModule<ModuleOptions>({
meta: {
name: 'nuxt-team-toolkit',
configKey: 'toolkit',
compatibility: { nuxt: '>=4.3.0' },
},
defaults: {
enabled: true,
apiBase: 'https://api.internal',
analytics: { enabled: false, sampleRate: 0.1 },
},
setup(options, nuxt) {
const logger = useLogger('toolkit')
if (!options.enabled) return logger.info('toolkit disabled by configuration')
if (!/^https?:\/\//.test(options.apiBase)) {
// Name the key, the value and the fix.
throw new Error(`[nuxt-team-toolkit] toolkit.apiBase must be an absolute URL, received "${options.apiBase}". Set it in nuxt.config.ts under \`toolkit.apiBase\`.`)
}
if (options.analytics.sampleRate < 0 || options.analytics.sampleRate > 1) {
throw new Error('[nuxt-team-toolkit] toolkit.analytics.sampleRate must be between 0 and 1.')
}
},
})
The escape hatch that prevents forks, in three lines:
// Build-time: other modules contribute before we generate anything.
const registry: Record<string, string> = {}
await nuxt.callHook('toolkit:extend', registry)
// Runtime: the app reacts once we are ready (declared by augmenting RuntimeNuxtHooks).
// nuxtApp.callHook('toolkit:ready', api)
// Advanced imports without reaching into dist/:
nuxt.options.alias['#toolkit'] = resolver.resolve('./runtime')
A layer's theme API, typed so a typo fails nuxt typecheck in the consuming app:
declare module 'nuxt/schema' {
interface AppConfigInput {
team?: {
brand?: string
density?: 'compact' | 'comfortable'
analytics?: { enabled?: boolean }
}
}
}
export {}
compact: true becomes compact: true, ultraCompact: true within two quarters, and now two booleans can contradict each other. Prefer density: 'compact' | 'comfortable': it extends without breaking and it documents the mutually exclusive choice.
A hook you expose is part of the public surface. Give it a typed signature, document what it receives and when it runs, and treat changing it as a breaking change — otherwise consumers will depend on it and you will break them silently.
nuxt-team-toolkit and trigger it from the playground. Read the message as a consumer would."Flat where possible, grouped into objects rather than a pile of booleans, every option typed in ModuleOptions, defaulted in defaults and documented in a table. An enabled flag so an app can switch it off without removing it, secrets in private runtime config and never in options, and validation in setup that fails with a message naming the config key and the fix. Then the escape hatches: a build hook so other modules can contribute, a runtime hook so the app can react, and an alias for advanced imports — because the alternative when I did not anticipate someone's case is that they fork. For a layer the same thinking applies to a different surface: prefixed component and composable names, typed app.config keys as the theme API, and a documented override recipe."
Senior craft
What changes when the thing you build is used by other teams rather than by end users - API design you cannot easily take back, release discipline, security, and review.
Team process, security and operations
Monorepo layout, release discipline, the security posture expected of someone who ships code into every app, and how support actually works.