Nuxt Module Boundaries — What They Enforce and How Modules Communicate
Photo by Jan van der Wolf on Pexels
The previous parts gave us a composed schema, generated client types, and a rendering layer that owns markup exactly once. Now all of that has to live somewhere in a codebase that several people change every week. So this article is about that somewhere: what a Nuxt module actually is, what it can and can’t enforce, and how modules talk to each other without recreating the very coupling the migration was supposed to get rid of.
The examples carry on with the fictional document application.
Table of Contents
- The Change That Reaches Into Three Folders
- What a Nuxt Module Actually Is
- Declaring a Module’s Public Interface
- Why a Module Is Not an Access Boundary
- Configuration Through Runtime Config
- Dependency Direction Between Module Categories
- Explicit Calls Hooks or Events
- Request State Inside a Module
- What a Module Decides at Startup
- The Change Revisited
- What’s Next
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 about it: a new page, a generated query taking two document ids, a few translated labels, and a diff view.
In a conventionally organized codebase, implementing that touches pages/, components/, composables/, utils/ and the localization files. Which is fine. What isn’t fine is what tends to happen along the way: the new composable imports a helper from inside the document-selection feature because that helper already normalizes revision ids; a component imports a formatter out of the localization internals instead of using its public function; and utils/ gains one more function that three completely unrelated features now depend on.
Not one of those imports is wrong in isolation, and every single one of them is invisible in the folder structure. Six months later you can’t refactor the localization internals without breaking a diff view that has nothing to do with localization, and nobody ever chose that dependency. It just accumulated. Folders are suggestions, that’s all. So 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 for you.
What a Nuxt Module Actually Is
A Nuxt module is a unit with two quite distinct lives, and most module bugs I’ve seen come from confusing the two.
index.ts runs at build time and at server startup, when Nuxt loads its configuration. It registers things. It doesn’t 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/, on the other hand, 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 play by different rules. index.ts sees one process and no users at all, whereas runtime/ code may well see hundreds of concurrent requests inside the same process. The section on request state further down is entirely about that difference.
Declaring a Module’s Public Interface
What a module registers in setup() is its public interface, and it’s a pleasantly 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 rather than the whole directory. Auto-imports also mean consumers never write a path into the module, so the module can reorganize its internals to its heart’s content, as long as everybody sticks to the declared names.
That last clause is doing an awful lot of work, which brings us neatly to the limitation I’m not going to gloss over.
Why a Module Is Not an Access Boundary
Nuxt modules don’t make files private. runtime/composables/internal/normalizeRevisionId.ts is an ordinary TypeScript file sitting in the same project, and any component at all can import it by path. The module system organizes. It doesn’t restrict.
So a 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 cross between modules in the wrong direction. This is the highest-value check of the three, because it fails in CI right next to the offending import. - Package exports. If a module is a real package, with its own
package.jsonand anexportsmap, then deep imports simply fail to resolve. That’s 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 actually written down somewhere. It’s no substitute for the first two, because reviewers approve imports that look reasonable, and every single one of the imports in this article’s opening scenario looked perfectly reasonable.
Deciding not to add the lint rule is a legitimate choice for a small team. Believing that folders or modules on their own create encapsulation is not, and it’s how you end up with exactly the coupling you reorganized to avoid.
Configuration Through Runtime Config
Modules need settings: endpoint URLs, timeouts, feature toggles, limits. Putting those in runtimeConfig rather than in some bespoke config file has a very concrete payoff, namely that values can be overridden by environment variables at container start. That’s what makes one build artifact runnable in several environments, the mechanism Part 2 sketched out and Part 16 goes into properly.
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 thing to get right on day one. Anything under runtimeConfig.public gets serialized into the HTML so the browser can read it, which makes it a published value: perfectly fine for a limit or a feature flag, and completely unacceptable for a token or an internal hostname. Once a secret has been put there and deployed even once, it has been disclosed, and moving it afterwards doesn’t undo that. Part 15 comes back to this as a trust-boundary question.
Declare the shape in types.d.ts so that useRuntimeConfig() is typed at both ends, and read configuration at the point of use rather than stashing it in a module-scope variable, for reasons the request-state section is about to make uncomfortably concrete.
Dependency Direction Between Module Categories
Once you’ve got dozens of modules, “modules depend on modules” isn’t a design. What keeps the graph navigable is a rule about direction, and the simplest useful one sorts modules into four categories:
Read the arrows as “may import from”. Core modules get depended upon and depend on nothing above them. Feature modules use core modules and do not import each other, and that single rule heads off most of the accumulated coupling from the opening scenario. Integration modules wrap external systems so that a feature never talks to a vendor SDK directly. And debug modules may look at everything, because looking at things is what they’re for, and they only ever get loaded outside production.
The rule is worth having precisely because it’s mechanically checkable: the same dependency-lint configuration that forbids deep imports can forbid a feature-to-feature edge too. A diagram on its own describes an intention that nothing enforces. The same rule written into a lint configuration fails the build the moment somebody breaks it.
Explicit Calls Hooks or Events
So if features can’t import each other, how do they cooperate? There are three mechanisms, and they’re not interchangeable. Reaching for 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 isn’t free.
A hook is for the case where a module has to offer an extension point without knowing who’s going to extend it. The provider calls the hook and anyone may register. Nuxt and Nitro use this pattern for their own lifecycles, and modules can define their own:
The inversion is the whole point: the diagnostics module decides where extension happens, features decide what they contribute, and neither one imports the other. The cost is that the type contract of the hook payload is now a convention you maintain by hand, and that the set of participants is only knowable at run time.
An event bus is for the narrowest case of all: the sender must not know that the receiver exists, and there may not be one. Opening a dialog from a composable is the canonical example, where the composable emits and whichever component renders modals listens. Keep it to that. An event bus makes “who handles this?” a question your tooling can’t answer, so every flow you build on it permanently costs you static navigability in exchange for decoupling you probably didn’t need. Events emitted during SSR have a further wrinkle, since the listeners in the browser don’t exist yet; Part 12 deals with that timing problem.
So the decision rule is: use the most direct mechanism that the dependency direction allows. Reach for indirection when a rule forbids the direct call, not when indirection feels more elegant.
Request State Inside a Module
And here’s the bug that the whole build-time/runtime split exists to prevent. The Nuxt server is one long-lived Node.js process handling many concurrent requests, which means a variable at module scope is shared by every last one 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 about, this works beautifully. In production it’s a cross-request singleton: two visitors comparing different documents can see each other’s selection, and the symptom shows up under load and then vanishes the moment you go looking for it.
The correct form uses Nuxt’s request-scoped state, which has the pleasant 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 from that. 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 every module-scope let, cache or client instance as a design decision that needs justifying. Some of them are perfectly legitimate, a connection pool for instance, and the test is simply 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’re about the application’s shape rather than about any particular request.
Whether the module is active in this environment. Debug tooling, so state inspectors, fixture loaders, panels that dump request context, must not exist in production at all. The reliable way to get that 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 never gets registered, its code isn’t part of the production entry graph and the bundler leaves it out. But do note what the actual guarantee is here: nothing is bundled because nothing referenced it. If an ordinary component imports a debug helper directly, then that helper ships, early return or no early return. So 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 happens to hold the credentials for the document API, then failing fast with a clear error is the right call, because an instance that starts up and then serves errors is much harder to diagnose than one that flatly refuses to start. If an unreachable dependency only degrades some non-essential feature, a recommendation panel say, then log it and carry on with a fallback. What you can’t do is leave the classification implicit. If an awaited call in setup() rejects and nobody handles it, you get neither of those two outcomes. Instead the module stops halfway through registering, so a composable or server route it was meant to add is simply absent, and the failure turns up later as “that endpoint returns 404” or “that auto-import is undefined”, messages which say absolutely nothing about an unreachable secret store. So decide, per dependency, whether to throw with a message that names the dependency or to catch and register a fallback.
Either way, keep setup() fast. Blocking on a slow network call during startup delays every deployment and every developer’s dev server. If work has to happen asynchronously, hang it off a lifecycle hook instead of awaiting it inline.
The Change Revisited
So, back to comparing two revisions. With modules as the unit, the feature becomes a new module that depends on core modules and nothing else: 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. And it keeps its selection in request-scoped state.
That helper it wanted to borrow from the document-selection feature is now either deliberately duplicated or deliberately promoted into a core module, and either way the choice gets made once, in a review, rather than quietly accumulating as an import.
What hasn’t changed is that all of this rests on conventions plus checks. A module boundary is exactly as strong as the lint rule enforcing it, which gives me the diagnostic worth taking away from this article: pick any two modules that have no business knowing about each other, and try to write the import. If nothing stops you before CI, then what you’ve got is a convention rather than a boundary. And knowing which of the two you have is more useful than having either one.
What’s Next
- Part 7: Two Toolkit Modules — Two modules whose whole job is generating the typed entry points this article assumed.
- Part 8: The Compose Pattern — How the design system module separates styling decisions from rendering.
- Part 9: Nuxt and a Headless CMS — What happens when editors, not routes, decide what a page contains.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Nuxt in Production