Nuxt’s module system is not just an add-on; it is the architecture. In a large enterprise Nuxt 4 stack, modules become the primary building blocks: they enforce boundaries, encapsulate behavior, and give you a clean mental model that scales beyond a handful of features and a couple of developers.
This masterclass walks through how to architect such a system:
- Why you need modules, not folders
- How to design module anatomy and categories
- How to use hooks and the event bus (sparingly) for communication
- How to structure UIKit, commands, and debug tooling as modules
- How to build production-ready Nuxt modules with conventions that hold up at scale
From Folders to Real Boundaries
Many applications start tidy: neat folders, a clear mental model, readable imports. A few years and a growing team later, you often see:
- Cross-cutting imports
- Circular dependencies
- Features reaching into each other’s internals
- “Just one more” helper in
utils/that becomes everyone’s dependency
The standard response is more folders: components/, composables/, services/, utils/. But folders are suggestions, not boundaries. Nothing stops a file in components/ from importing services/checkout/ internals that were never meant to be public.
Modules are boundaries. A module decides:
- What it exports (the public API)
- What stays internal
- How it is configured
- How it is documented
Nuxt modules are exactly this abstraction.
Modules Are the Architecture
Nuxt modules are not plugins or random utility packages. They are the architectural units of a Nuxt application:
- They run at build time to:
- Register auto-imported composables and components
- Add server handlers and middleware
- Inject plugins
- Modify Nuxt configuration
- Hook into Nuxt and Nitro lifecycle events
- They provide the runtime code that powers your app:
- Composables
- Components
- Server plugins
- Request middleware
In a stack with 35+ custom modules, module development is feature development. You don’t “add a feature”; you “add a module”. That’s the shift.
Module Anatomy: One Shape for Everything
Every module follows the same structure:
flowchart TD A[modules/my-module/] --> B["index.ts<br/>(Module definition - build time)"] A --> C[runtime/] A --> D["types.d.ts<br/>(type declarations)"] A --> E["README.md<br/>(documentation)"] C --> C1["composables/<br/>useMyFeature.ts"] C --> C2["components/<br/>MyComponent.vue"] C --> C3[server/] C --> C4["plugins/<br/>my-plugin.ts"] C3 --> C3a[middleware/] C3 --> C3b["plugins/<br/>(server plugins)"]
This structure codifies the two phases of a module’s life.
Phase 1: Build Time (index.ts)
index.ts calls defineNuxtModule(). This code runs when Nuxt starts (dev server or production build):
export default defineNuxtModule({
meta: { name: 'my-module' },
setup(options, nuxt) {
// Register composables for auto-import
addImports({
name: 'useMyFeature',
from: resolve('./runtime/composables/useMyFeature'),
})
// Add server handlers
addServerHandler({
route: '/api/my-module/status',
handler: resolve('./runtime/server/status.get'),
})
// Add plugins
addPlugin(resolve('./runtime/plugins/my-plugin'))
// Configure runtime via runtimeConfig
nuxt.options.runtimeConfig.myModule = {
enabled: true,
apiUrl: process.env.MY_MODULE_API_URL,
}
// Hook into the build lifecycle
nuxt.hook('build:before', () => {
// e.g., validate config, generate files
})
},
})
This is where the module declares its presence in the app: what it auto-imports, which APIs it exposes, and how it shapes Nuxt’s configuration.
flowchart LR A[Nuxt startup] --> B["index.ts<br/>defineNuxtModule setup()"] B --> C[Register auto-imports] B --> D[Add server handlers] B --> E[Add plugins] B --> F[Configure runtimeConfig] B --> G[Register build hooks]
Phase 2: Runtime (runtime/)
Everything under runtime/ is executed when the app runs (SSR and/or browser):
runtime/
├─ composables/useMyFeature.ts # Auto-imported composable
│ → Used directly in pages/components
│
├─ server/plugin.ts # Nitro server plugin
│ → Runs once at server start
│ → Not shipped to the browser
│
├─ server/middleware/guard.ts # Server middleware
│ → Runs on every HTTP request
│
└─ plugins/init.client.ts # Client-only Nuxt plugin
→ Runs once after hydration
flowchart TD A[runtime/] --> B["composables/useMyFeature.ts<br/>Used in pages/components"] A --> C["server/plugin.ts<br/>Runs once at server start<br/>Server only"] A --> D["server/middleware/guard.ts<br/>Runs on every HTTP request"] A --> E["plugins/init.client.ts<br/>Client-only plugin<br/>Runs after hydration"]
Mental model:
index.ts= build-time configurationruntime/= application-time behavior
Many subtle bugs come from mixing up those two.
Module Categories and Dependency Rules
With 35+ modules, categorization stops being a luxury and becomes a necessity. A typical categorization in a large enterprise application looks like this:
flowchart TB
subgraph Core[Core Modules]
direction LR
core1[graphql-server]
core2[gqlt]
core3[i18nt]
core4[logging]
core5[uikit]
end
subgraph Features[Feature Modules]
direction TB
f1[chatbot]
f2[shopping-cart]
f3[product-search]
f4[notifications]
f5[forms]
end
subgraph Integrations[Integration Modules]
direction TB
i1[cms-client]
i2[cloud-auth]
i3[secrets-vault]
i4[app-insights]
i5[tag-manager]
end
subgraph Debug[Debug Modules]
direction TB
d1[debug-panel]
d2[debug-chatbot]
d3[debug-cms]
d4[debug-abtests]
d5[debug-pinia]
end
Core --> Features
Core --> Integrations
Features --> Debug
Integrations --> Debug
Strict dependency direction:
- Core modules sit at the bottom:
- Everything can depend on them
- They do not depend on feature, integration, or debug modules
- Feature modules implement user-facing capabilities:
- They depend only on core modules
- They never import each other
- Integration modules wrap external systems:
- They depend on core modules
- They do not depend on feature modules
- Debug modules depend on whatever they inspect:
- They are loaded only in non-production environments
- They are fully tree-shaken out of production builds
This explicit dependency rule is where modular monolith discipline lives in Nuxt.
Hooks: The Communication Layer Between Modules
Beyond direct imports, Nuxt exposes a hook system that lets modules react to lifecycle events or extend each other without coupling.
Common Build-Time Hooks
These run while Nuxt is bootstrapping:
flowchart TB
subgraph BuildTimeHooks[Build-Time Hooks]
direction TB
h1["modules:before<br/>Before any module runs"]
h2["modules:done<br/>After all modules"]
h3["build:before<br/>Before Vite/webpack"]
h4["build:done<br/>After build completes"]
h5["components:dirs<br/>Register component dirs"]
h6["imports:dirs<br/>Register import dirs"]
end
A[Nuxt bootstrap] --> h1 --> h2 --> h3 --> h4
A --> h5
A --> h6
Use these to:
- Add or alter component directories
- Register auto-import directories
- Generate code or files before the build
- Validate configuration
Runtime Hooks (Nitro)
At runtime, Nitro exposes hooks for server-side behavior:
flowchart TB
subgraph RuntimeHooks[Runtime Hooks]
direction TB
r1["request<br/>On every HTTP request"]
r2["render:html<br/>Modify SSR HTML output"]
r3["afterResponse<br/>After response sent"]
r4["error<br/>On unhandled error"]
end
C[Incoming HTTP request] --> r1 --> r2 --> r3
C --> r4
These are how modules implement cross-cutting server concerns (logging, security headers, etc.) without importing each other.
Custom Hooks: Inversion of Control Between Modules
Modules can define and emit custom hooks to allow other modules to plug into them. For example, a debug chatbot module can discover tools from other modules like this:
Module A (debug-chatbot): defines hook
nitroApp.hooks.callHook('mcp:setup', toolRegistry)
Module B (shopping-cart): listens for hook
nitroApp.hooks.hook('mcp:setup', (tools) => {
tools.push({
name: 'get-cart',
handler: () => getCartState()
})
})
Result:
- Module A exposes an extension point
- Module B registers tools into it
- No direct imports between A and B
sequenceDiagram
participant A as Module A<br/>(debug-chatbot)
participant Nitro as nitroApp.hooks
participant B as Module B<br/>(shopping-cart)
A->>Nitro: callHook('mcp:setup', toolRegistry)
Nitro->>B: mcp:setup(tools)
B-->>Nitro: tools.push({ name: 'get-cart', handler })
Nitro-->>A: toolRegistry populated
This is inversion of control: the consumer (debug-chatbot) defines where to extend; producers (feature modules) decide how.
Runtime Configuration as the Configuration Backbone
Module configuration lives in nuxt.options.runtimeConfig, not in custom config files.
// modules/my-module/index.ts
export default defineNuxtModule({
setup(options, nuxt) {
nuxt.options.runtimeConfig.myModule = {
enabled: true,
apiUrl: process.env.MY_MODULE_API_URL || 'https://default.api.com',
cacheTtl: parseInt(process.env.MY_MODULE_CACHE_TTL || '3600'),
}
},
})
flowchart LR A[Environment variables] --> B["defineNuxtModule setup()"] B --> C[nuxt.options.runtimeConfig.myModule] C --> D["Server code<br/>useRuntimeConfig()"] C --> E["Client composables<br/>useRuntimeConfig()"]
Why this matters:
- Container-friendly: everything can be overridden via environment variables
- Typed: config shape is declared in
types.d.ts - Accessible: composables and server code read via
useRuntimeConfig() - No home-grown config loaders: fewer failure modes, simpler mental model
README as a First-Class API Surface
Every module ships with a README that follows a standard template and is updated with the code:
# Module: my-module
## Purpose
One-paragraph description of what this module does.
## Configuration
| Variable | Default | Description |
|--------------------|---------|------------------|
| MY_MODULE_ENABLED | true | Enable/disable |
| MY_MODULE_API_URL | ... | API endpoint |
## Composables
### useMyFeature()
Description, parameters, return value.
## Server Handlers
### /api/my-module/status
GET — returns module status.
## Dependencies
- Requires: graphql-server module
- Optional: logging module
## Architecture Decisions
Why this module exists and what alternatives were considered.
Code tells you how; the README tells you why. Without it, every module becomes a reverse-engineering exercise.
Environment-Aware Modules and Debug Tooling
Debug and development-only modules must never leak into production. The pattern:
export default defineNuxtModule({
setup(options, nuxt) {
const env = nuxt.options.runtimeConfig.public.environment
if (env === 'production') {
// Do nothing in production
return
}
// Non-production only: register debug UI, plugins, etc.
addPlugin(resolve('./runtime/plugins/debug-panel.client'))
},
})
Because the module returns early:
- Its composables and components are never registered in production
- Tree-shaking can remove their code from the final bundles
- Debug tooling stays powerful in dev and invisible in prod
flowchart TD A["defineNuxtModule setup()"] --> B[Read runtimeConfig.public.environment] B -->|production| C["Return early<br/>No debug plugins registered"] B -->|non-production| D[Register debug-panel.client plugin] D --> E[Debug UI available only in dev/test]
Graceful Degradation: Critical vs Optional Dependencies
Enterprise modules often depend on external systems (secret stores, CMS, analytics, identity providers). Not all failures are equal.
flowchart TD A["Module startup<br/>(secrets-vault)"] --> B[Connect to secrets vault] B -->|Success| C[Load secrets into runtimeConfig] B -->|Failure| D[Classify secrets] D --> E[Critical secrets?] E -->|Yes| F["Fail fast<br/>throw clear error"] E -->|No| G["Log warning<br/>Use defaults/fallbacks"]
This decision lives in the module’s setup():
- Mark secrets or integrations as critical (app cannot function without them)
- Mark the rest as optional and provide sane fallbacks
The Event Bus: One Narrow Use Case
A global event bus is tempting. Nuxt’s useEventBus (built on EventEmitter3) provides a publish/subscribe mechanism. It exists in this architecture for exactly one reason:
> Module-to-application communication without coupling modules to UI components.
Modules do not communicate with each other via the event bus.
Example: Dialog System
The UIKit module defines useDialog() which broadcasts dialog events. The application layer listens and renders the actual modal components.
Module (useDialog) Application (modal component)
┌────────────────────┐ ┌─────────────────────┐
│ sendEvent( │ ──── event ───▶ │ useEventBusListener( │
│ 'dialog:open', │ │ 'dialog:open', │
│ { name: 'faq' } │ │ ({ name }) => { │
│ ) │ │ isOpen = true │
└────────────────────┘ │ } │
│ ) │
└─────────────────────┘
sequenceDiagram
participant M as Module<br/>useDialog()
participant Bus as Event bus<br/>useEventBus
participant App as Modal component
M->>Bus: emit 'dialog:open'<br/>{ name: 'faq' }
Bus->>App: 'dialog:open' payload
App->>App: isOpen = true<br/>render FAQ modal
Any composable can trigger a dialog without importing the dialog component.
Example: Cookie Consent
When a third-party cookie consent manager records user consent, it emits an event. The application listens and persists consent state in cookies. The module doesn’t need to know where or how persistence happens.
That’s it. These are the only uses across the entire application.
Why Not More?
An event bus quickly becomes an invisible web of side effects:
- Handlers are separated from senders
- “Find all references” won’t reveal who emits an event
- Debugging requires mental simulation rather than static navigation
Direct function calls remain:
- Easier to trace
- Easier to refactor
- Visible in tooling
The rule:
> Use the event bus only when the sender must not know about the receiver. > Accept that those flows will be harder to debug.
SSR Event Replay
What about events emitted during SSR before client listeners are attached?
Events sent during SSR are stored in Nuxt’s useState and automatically replayed on the client after hydration:
sequenceDiagram participant S as Server participant State as useState store participant C as Client S->>S: Module loads during SSR S->>State: Store emitted events State-->>C: Serialized in SSR HTML C->>C: Hydration C->>State: Read stored events State-->>C: Replay events to listeners
This closes the gap between server-side emission and client-side listeners, ensuring that no event is lost during the hydration window.
The Command Pattern: Encapsulating Actions
Buttons, links, and form submissions often carry a surprising amount of logic:
- Loading state
- Disabled conditions
- Dynamic labels
- Error handling
- The action itself
Spread across templates, this becomes brittle and repetitive.
The useCommand composable wraps all of this into a single reactive object:
const submitCommand = useCommand({
execute: async () => {
await submitForm(formData.value)
router.push('/confirmation')
},
loading: () => isSubmitting.value,
disabled: () => !formValid.value || isSubmitting.value,
label: () => (isSubmitting.value ? 'Submitting...' : 'Submit Order'),
})
Template usage is simple:
<Button
@click="submitCommand.execute"
:loading="submitCommand.loading"
:disabled="submitCommand.disabled"
>
{{ submitCommand.label }}
</Button>
flowchart LR
A["Business logic<br/>submitForm + navigation"] --> B[useCommand]
B --> C["submitCommand object<br/>{ execute, loading, disabled, label }"]
C --> D["UI components<br/>(Button, toolbar, dialog)"]
Passable Commands
The real power: commands are values, so they can be passed around.
<!-- Parent defines behavior, child renders it -->
<CheckoutStep :submit-command="submitCommand" />
<!-- Or: child exposes a command upward -->
<FormStep v-model:command="stepCommand" />
This achieves:
- Decoupling behavior from presentation:
- The component that renders the button doesn’t define what it does
- The component defining behavior doesn’t care how it’s rendered
- Testability:
- Commands are plain objects; test them without mounting any components
- Reusability:
- The same command can be used in different UI contexts (toolbar, footer, dialog, etc.)
The UIKit Compose Pattern: Testable, Typed Styling
UIKit uses a compose pattern to separate:
- Style computation (pure TypeScript) from
- Template markup (Vue SFCs)
Each component is split into two files:
| File | Responsibility |
|---|---|
composeButton.ts | Interface + classes(props) style function |
Button.vue | Thin template calling the compose function |
The compose file:
// composeButton.ts
export interface ButtonProps {
variant: 'solid' | 'outline' | 'ghost'
size: 'sm' | 'md' | 'lg'
color: 'primary' | 'secondary' | 'danger'
fullWidth: boolean
}
export function classes(props: ButtonProps): string {
// return something like:
// "btn btn-solid btn-lg btn-primary w-full"
}
The Vue component:
<!-- Button.vue -->
<template>
<button :class="classes">
<slot />
</button>
</template>
<script setup lang="ts">
import { composeButton } from './composeButton'
const props = defineProps<composeButton.ButtonProps>()
const classes = computed(() => composeButton.classes(props))
</script>
flowchart LR A["Design system<br/>Button variants"] --> B[ButtonProps interface] B --> C["classes(props): string<br/>pure TS style function"] C --> D["Button.vue<br/>template + computed classes"] D --> E["Rendered button<br/>with correct classes"]
Why This Works at Scale
- Props interface is the API contract
TypeScript enforces correct prop usage everywhere the component is used.
classes()is unit-testable
It’s a pure function from props to class strings. No Vue mounting required.
- Design/dev shared vocabulary
If the design system says variant: 'outline', the interface says the same. No translation layer.
- AI-friendly
An AI assistant can understand a component’s full API from the compose file alone.
Compared to , style logic in TypeScript is:
- Composable
- Reusable
- Discoverable by tooling
- Easy to refactor without hunting through CSS selectors
Scaffolding: Enforcing Consistency by Default
With dozens of modules, consistency is enforced by a scaffolding generator (e.g., plop):
$ yarn plop
? What type of module? Feature Module
? Module name: loyalty-program
? Include server handlers? Yes
? Include DevTools tab? No
Created:
✓ modules/loyalty-program/index.ts
✓ modules/loyalty-program/runtime/composables/useLoyaltyProgram.ts
✓ modules/loyalty-program/runtime/server/plugin.ts
✓ modules/loyalty-program/types.d.ts
✓ modules/loyalty-program/README.md
flowchart TD A["Run scaffolder<br/>yarn plop"] --> B["Answer prompts<br/>type, name, options"] B --> C[Generate module skeleton] C --> C1[index.ts] C --> C2[runtime/composables/useLoyaltyProgram.ts] C --> C3[runtime/server/plugin.ts] C --> C4[types.d.ts] C --> C5[README.md]
Every new module:
- Starts with the correct directory layout
- Has the expected boilerplate
- Includes a README template
Developers focus on logic; the scaffolding takes care of wiring and conventions.
SSR and the Global State Trap
Nuxt (and Nitro) render pages on the server, often handling many concurrent requests. In this context, module-scope variables are shared across all requests:
// DANGEROUS: Shared across all users and requests
let requestCount = 0
export function useCounter() {
requestCount++ // increments for EVERY user
return requestCount
}
In SSR, this becomes a cross-request singleton — a classic source of heisenbugs.
Instead, use per-request state:
// SAFE: Per-request, per-user
export function useCounter() {
const count = useState('counter', () => 0)
count.value++
return count
}
flowchart LR
subgraph Bad[Global mutable state]
G1["Module-scope variable<br/>let requestCount = 0"]
G2[All requests share same counter]
end
subgraph Good[Per-request state]
P1["useState('counter', () => 0)"]
P2[Each request gets its own counter]
end
G1 --> G2
P1 --> P2
General rules:
- Avoid global mutable state in module scope
- Use
useStateoruseRequestEvent()for request-specific data - Keep build-time setup (
index.ts) free of per-request concerns
Anti-Patterns (and Their Better Alternatives)
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Importing between feature modules | Tight coupling, hard to evolve boundaries | Use hooks or the app’s event bus where appropriate |
Build-time side effects in index.ts | Order-dependent execution | Express intent via Nuxt/Nitro hooks |
| Global state in module scope (SSR) | Shared across all requests | Use useState or per-request context |
Blocking async work in setup() | Slows or blocks dev server startup | Use nuxt.hook('ready', async () => ...) |
Hardcoded process.env.NODE_ENV checks | Scattered environment logic | Use runtimeConfig.public.environment |
flowchart TB
A[Anti-pattern] --> B[Operational / maintenance problem]
B --> C[Recommended Nuxt pattern]
A1[Feature imports another feature] -->|tight coupling| B
C1[Use hooks or event bus] --> C
A2[Global mutable state in module scope] -->|cross-request leaks| B
C2[useState / per-request context] --> C
A3["Blocking async in setup()"] -->|slow startup| B
C3["nuxt.hook('ready', async ...)"] --> C
Lessons Learned
1. Modules are boundaries, folders are suggestions A folder named checkout/ doesn’t prevent payments/ from importing its internals. A Nuxt module with auto-imported composables and components creates a real API surface: consumers use the public exports; everything else is private.
2. The build-time vs runtime split is the key mental model Many module bugs come from confusing index.ts (build time) with runtime/ code (request/application time). Once that line is clear, modules behave predictably.
3. The event bus is a last resort, not a pattern Every event is an invisible dependency. Prefer direct function calls and imports. Use the event bus only when the sender must not know about the receiver (e.g., module-to-app notifications).
4. Debug modules should be first-class citizens Debug tooling is not “throwaway code”. Encapsulating it into proper, environment-aware modules with docs makes it reliable and keeps production builds clean.
5. The compose pattern beats scoped styles at scale Style logic as TypeScript functions is testable, composable, and type-safe. hides logic from tooling, is hard to share, and encourages duplication.
6. Consistent conventions supercharge onboarding With a standard module structure, runtime config, and README template, learning one module means you can navigate all of them.
7. The README is the most important file in a module It captures the rationale and contract of the module. Without it, even well-structured code becomes a puzzle.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.





