Senior craft

Designing the API

Options, conventions, escape hatches and documentation for a module, and the equivalent public surface for a layer.

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.

Know — a module's API

  • Options flat where possible, grouped into objects instead of boolean explosions. analytics: { enabled, sampleRate } beats analyticsEnabled plus analyticsSampleRate. Every option typed in ModuleOptions, defaulted in defaults, documented in a table in the README.
  • Validate early and fail with a message that names the fix, including the configKey, so the consumer knows where to edit.
  • Conventions consumers already expect: enabled: boolean to switch the module off without removing it, configKey in camelCase matching the package name, runtime APIs prefixed consistently (useToolkit*, <Toolkit*>).
  • Escape hatches: a build hook (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.
  • Compatibility declared (meta.compatibility.nuxt), with a version matrix in the README and CI running against nightly.
  • DX polish: a scoped useLogger('toolkit'), a debug option that prints your setup timings, and a DevTools tab when the module has state worth inspecting.

Know — a layer's API

A layer has no options object, so its surface is files and keys:

SurfaceExampleHow a consumer changes it
Component names<TeamButton>override by path, or wrap through #layers/base/...
Composables and utilsuseTeamAuth()override by name, or call and wrap
app.config keysteam.densityset the key in the app's app.config.ts
runtimeConfig keysNUXT_TEAM_API_TOKENenvironment variable per deployment
Routes and layouts/account/**, layouts/team.vuesame-path override, or pages:extend to remove
Preset modules@nuxt/ui, @nuxt/eslintui: false to disable, options to adjust
  • Prefix everything so nothing collides with an app or another layer, and so adoption is greppable.
  • 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.
  • Keep internals unreachable: components you do not want depended on go in a directory you register with pathPrefix: false and an underscore-prefixed name, or simply are not registered at all.

How it works

Validating a module's options so the failure is actionable:

src/module.ts
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:

src/module.ts
// 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:

layers/base/app/types/app-config.d.ts
declare module 'nuxt/schema' {
  interface AppConfigInput {
    team?: {
      brand?: string
      density?: 'compact' | 'comfortable'
      analytics?: { enabled?: boolean }
    }
  }
}
export {}
Gotcha· Booleans that should have been an enum

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.

Gotcha· An escape hatch is a promise too

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.

Exercise

Exercise
  • Design the options API for an internal analytics module on paper: options, defaults, where secrets go, which hooks you expose, what you deliberately leave out. Then write the README options table before writing any code.
  • Add validation with an actionable error message to nuxt-team-toolkit and trigger it from the playground. Read the message as a consumer would.
  • List the public surface of your layer, then try to shrink it by one item.

Be able to say

Be able to say· How do you design the options API for an internal module?

"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."