Minimalist view of a modern building facade with clean lines against a clear blue sky. Hero image credit: Photo by Jan van der Wolf on Pexels

Part 6 of the Nuxt and .NET series.

The previous parts produced a composed schema, generated client types, and a rendering layer that owns markup exactly once. All of that now has to live somewhere in a codebase that several people change every week. This article is about that somewhere: what a Nuxt module is, what it can and cannot enforce, and how modules talk to each other without recreating the coupling the migration was meant to remove.

The examples continue the fictional document application.

Table of Contents

The Change That Reaches Into Three Folders

The feature request is to let users compare two revisions of a document side by side. Nothing exotic: a new page, a generated query with two document ids, a few translated labels, and a diff view.

In a conventionally organized codebase, implementing it touches pages/, components/, composables/, utils/, and the localization files. That is fine. What is not fine is what tends to happen on the way there: the new composable imports a helper from inside the document-selection feature because it already normalizes revision ids; a component imports a formatter out of the localization internals rather than using its public function; and utils/ gains one more function that three unrelated features now depend on.

None of those imports is wrong in isolation, and every one of them is invisible in the folder structure. Six months later the localization internals cannot be refactored without breaking a diff view that has nothing to do with localization, and nobody chose that dependency — it accumulated. Folders are suggestions. The question this article answers is what an actual boundary looks like in a Nuxt application, and which parts of it a machine can check.

What a Nuxt Module Actually Is

A Nuxt module is a unit with two distinct lives, and most module bugs come from confusing them.

flowchart TB A["modules/revision-compare/"] A --> IDX["index.ts<br/>runs when Nuxt starts:<br/>registration only"] A --> RT["runtime/"] A --> TY["types.d.ts"] A --> RM["README.md"] RT --> C1["composables/<br/>used in pages and components"] RT --> C2["components/"] RT --> C3["server/plugins/<br/>once at server start"] RT --> C4["server/middleware/<br/>every HTTP request"] RT --> C5["plugins/*.client.ts<br/>after hydration, in the browser"]

index.ts runs at build time and at server startup, when Nuxt loads its configuration. It registers things; it does not serve users:

export default defineNuxtModule({
  meta: { name: 'revision-compare' },
  setup(options, nuxt) {
    // Make the composable available without an import statement
    addImports({
      name: 'useRevisionCompare',
      from: resolve('./runtime/composables/useRevisionCompare'),
    })

    // Expose a server endpoint owned by this module
    addServerHandler({
      route: '/api/revision-compare/status',
      handler: resolve('./runtime/server/status.get'),
    })

    addPlugin(resolve('./runtime/plugins/init'))

    nuxt.options.runtimeConfig.revisionCompare = {
      maxRevisions: Number(process.env.REVISION_COMPARE_MAX ?? 5),
    }
  },
})

Everything under runtime/ runs while the application serves requests — on the server during SSR, in the browser after hydration, or both. The distinction matters because the two phases have different rules: index.ts sees one process and no users, while runtime/ code may see hundreds of concurrent requests in the same process. The section on request state is entirely about that difference.

Declaring a Module’s Public Interface

What a module registers in setup() is its public interface, and it is a short list: auto-imported composables, components, server routes, injected plugins, and a typed configuration shape. Consumers use those names and nothing else.

This is a genuine improvement over folder conventions for one reason: the interface is declared in one file. To find out what the revision-compare module offers, you read its index.ts and its types.d.ts, not the whole directory. Auto-imports also mean consumers never write a path into the module, so the module can reorganize its internals freely — as long as everyone stays on the declared names.

That last clause is doing a lot of work, which brings us to the limitation this article refuses to gloss over.

Why a Module Is Not an Access Boundary

Nuxt modules do not make files private. runtime/composables/internal/normalizeRevisionId.ts is an ordinary TypeScript file in the same project, and any component can import it by path. The module system organizes; it does not restrict.

So the boundary is only real if something checks it. Three mechanisms do, in ascending order of effort:

  • Dependency linting. An ESLint boundary rule or a tool like dependency-cruiser can forbid imports that reach past a module’s entry point, or that cross between modules in the wrong direction. This is the highest-value check because it fails in CI, next to the offending import.
  • Package exports. If a module is a real package — its own package.json with an exports map — then deep imports fail to resolve. Stronger, but it means workspace packaging and versioning for every module, which is a substantial commitment.
  • Review. Effective when reviewers know the rules and the rules are written down. Not a substitute for the first two, because reviewers approve imports that look reasonable, and every one of the imports in this article’s opening scenario looked reasonable.

Deciding not to add the lint rule is a legitimate choice for a small team. Believing that folders or modules alone create encapsulation is not; it is how you end up with the coupling you reorganized to avoid.

Configuration Through Runtime Config

Modules need settings — endpoint URLs, timeouts, feature toggles, limits. Putting them in runtimeConfig rather than a bespoke config file has a concrete payoff: values can be overridden by environment variables at container start, which is what makes one build artifact runnable in several environments, the mechanism Part 2 sketched and Part 16 details.

nuxt.options.runtimeConfig.documentApi = {
  // Server-only: never sent to the browser
  apiUrl: process.env.DOCUMENT_API_URL,
  apiToken: process.env.DOCUMENT_API_TOKEN,
  public: undefined,
}

nuxt.options.runtimeConfig.public.documentApi = {
  // Serialized into the page and readable by anyone
  maxRevisions: 5,
}

The split between private and public is the part to get right on the first day. Anything under runtimeConfig.public is serialized into the HTML so the browser can read it, which makes it a published value: fine for a limit or a feature flag, unacceptable for a token or an internal hostname. Once a secret has been placed there once and deployed, it has been disclosed, and moving it later does not undo that. Part 15 returns to this as a trust-boundary question.

Declare the shape in types.d.ts so useRuntimeConfig() is typed at both ends, and read configuration at the point of use rather than caching it in a module-scope variable — for reasons that the request-state section makes uncomfortably concrete.

Dependency Direction Between Module Categories

Once there are dozens of modules, “modules depend on modules” is not a design. What keeps the graph navigable is a rule about direction, and the simplest useful one sorts modules into four categories:

flowchart TB CORE["Core<br/>data access, localization,<br/>design system, diagnostics"] FEAT["Feature<br/>revision compare, search,<br/>notifications"] INT["Integration<br/>CMS, identity,<br/>secret store, telemetry"] DBG["Debug<br/>non-production inspection tools"] FEAT --> CORE INT --> CORE DBG --> FEAT DBG --> INT DBG --> CORE

Read the arrows as “may import from”. Core modules are depended upon and depend on nothing above them. Feature modules use core modules and do not import each other — that single rule prevents most of the accumulated coupling in the opening scenario. Integration modules wrap external systems so that a feature never talks to a vendor SDK directly. Debug modules may look at everything, because they exist to inspect, and they are loaded only outside production.

The rule is worth having because it is mechanically checkable: the same dependency-lint configuration that forbids deep imports can forbid a feature-to-feature edge. An architecture diagram nobody can verify is a poster; the same diagram expressed as a lint rule is a boundary.

Explicit Calls Hooks or Events

If features cannot import each other, how do they cooperate? There are three mechanisms, and they are not interchangeable — picking the most decoupled one by default is a common and expensive mistake.

A typed function call through a public entry point is the right default. When the revision-compare feature needs a document query, it calls the data-access module’s generated composable. The dependency is visible, “find all references” works, the types check, and a refactor that breaks the contract breaks the build. Decoupling that costs you all of that is not free.

A hook is for the case where a module must offer an extension point without knowing who extends it. The provider calls the hook; anyone may register. Nuxt and Nitro use this pattern for their own lifecycles, and modules can define their own:

sequenceDiagram participant D as Diagnostics module<br/>(defines the extension point) participant H as nitroApp.hooks participant F as Feature module<br/>(registers a probe) F->>H: hook('diagnostics:collect', fn) Note over D,F: later, when a diagnostics page is requested D->>H: callHook('diagnostics:collect', registry) H->>F: fn(registry) F-->>H: registry.add({ name: 'revision-compare', status }) H-->>D: populated registry

The inversion is the point: the diagnostics module decides where extension happens, features decide what to contribute, and neither imports the other. The cost is that the type contract of the hook payload is now a convention you maintain, and that the set of participants is only knowable at runtime.

An event bus is for the narrowest case: the sender must not know the receiver exists, and there may be none. Opening a dialog from a composable is the canonical example — the composable emits, and whichever component renders modals listens. Restrict it to that. An event bus makes “who handles this?” unanswerable by tooling, so every flow built on it costs you static navigability permanently in exchange for decoupling you probably did not need. Events emitted during SSR have a further wrinkle, because listeners in the browser do not exist yet; Part 12 deals with that timing problem.

The decision rule: use the most direct mechanism that the dependency direction permits. Reach for indirection when a rule forbids the direct call, not when it feels more elegant.

Request State Inside a Module

Here is the bug that the build-time/runtime split exists to prevent. The Nuxt server is one long-lived Node.js process handling many concurrent requests, so a variable at module scope is shared by all of them:

// Wrong: one value for every concurrent visitor
let currentDocumentId: string | null = null

export function useSelectedDocument() {
  return { currentDocumentId, select: (id: string) => { currentDocumentId = id } }
}

In development, with one person clicking, this works perfectly. In production it is a cross-request singleton: two visitors comparing different documents can see each other’s selection, and the symptom appears under load and disappears when you look for it.

flowchart LR subgraph BAD["Module-scope variable"] B1["let currentDocumentId"] --> B2["Shared by every<br/>concurrent request"] end subgraph GOOD["Per-request state"] G1["useState('selected-document')"] --> G2["One value per request,<br/>serialized for hydration"] end

The correct form uses Nuxt’s request-scoped state, which has the additional property of being serialized into the payload so the browser hydrates with the same value:

export function useSelectedDocument() {
  const selected = useState<string | null>('selected-document', () => null)
  return { selected, select: (id: string) => { selected.value = id } }
}

Three practical rules follow. Keep per-request data in useState or on the request event via useRequestEvent(). Keep index.ts free of anything request-specific, since it runs once and has no request to speak of. And treat any module-scope let, cache, or client instance as a design decision to be justified — some are legitimate, like a connection pool, and the test is whether the value would be wrong if two different visitors shared it.

What a Module Decides at Startup

Two decisions belong in setup() because they are about the application’s shape rather than about any request.

Whether the module is active in this environment. Debug tooling — state inspectors, fixture loaders, panels that dump request context — must not exist in production. The reliable way is to return before registering anything:

setup(options, nuxt) {
  if (nuxt.options.runtimeConfig.public.environment === 'production') return
  addPlugin(resolve('./runtime/plugins/debug-panel.client'))
}

Because the plugin is never registered, its code is not part of the production entry graph and the bundler does not include it. Note the actual guarantee: nothing is bundled because nothing referenced it. If an ordinary component imports a debug helper directly, that helper ships, early return or not. Gate the module, and keep the debug code reachable only through it.

Whether a missing dependency is fatal. Modules that wrap external systems have to classify their dependencies at startup. If the secret store is unreachable and it holds the credentials for the document API, failing fast with a clear error is right: an instance that starts and then serves errors is harder to diagnose than one that refuses to start. If an unreachable dependency only degrades a non-essential feature — a recommendation panel, say — log it and continue with a fallback. What you cannot do is leave the classification implicit, because the default behaviour of an unhandled rejection in setup() is neither of those things.

Keep setup() fast either way. Blocking on a slow network call during startup delays every deployment and every developer’s dev server; if work must happen asynchronously, hang it on a lifecycle hook rather than awaiting it inline.

The Change Revisited

Back to comparing two revisions. With modules as the unit, the feature is a new module that depends on core modules only: it calls the data-access module’s generated composable for the documents, uses the localization module’s public API for its labels, and renders with the design system’s components. It registers one composable and one component. It has a typed configuration entry for the maximum number of revisions. It keeps its selection in request-scoped state.

The helper it wanted to borrow from the document-selection feature is now either duplicated deliberately or promoted into a core module deliberately — and that choice is made once, in a review, instead of accumulating as an import.

What has not changed is that all of this rests on conventions plus checks. A module boundary is exactly as strong as the lint rule that enforces it, which suggests the diagnostic worth taking from this article: pick any two modules that should not know about each other and try to write the import. If nothing stops you before CI, you have a convention, not a boundary — and knowing which one you have is more useful than either.

What’s Next


Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.