Architecting Enterprise Nuxt with Custom Modules

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 configuration
  • runtime/ = 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:

FileResponsibility
composeButton.tsInterface + classes(props) style function
Button.vueThin 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

  1. Props interface is the API contract

TypeScript enforces correct prop usage everywhere the component is used.

  1. classes() is unit-testable

It’s a pure function from props to class strings. No Vue mounting required.

  1. Design/dev shared vocabulary

If the design system says variant: 'outline', the interface says the same. No translation layer.

  1. AI-friendly

An AI assistant can understand a component’s full API from the compose file alone.

Compared to