The Nuxt Evolution: From Monolith to Scalable Architectures

The Nuxt Evolution: From Monolith to Scalable Architectures

When you begin a new Nuxt project, you're greeted with a beautifully simple and intuitive folder structure. The conventions are clear: put your pages in pages/, components in components/, and so on. This file-based routing and auto-import system is a cornerstone of Nuxt's exceptional developer experience, allowing for rapid prototyping and feature development. For small to medium-sized applications, this default structure works perfectly—it's a monolith, but a well-organized one.

However, as an application grows in complexity and the team expands, this simplicity can become a double-edged sword. The components/ directory swells with hundreds of files, shared logic becomes tangled, and it becomes difficult to see where one feature ends and another begins. This is the point where architecture is no longer an academic concern but a practical necessity for survival.

Fortunately, Nuxt's modular design provides a clear path forward, allowing you to evolve your application's architecture to handle increasing scale. This guide explores the journey from a simple monolith to highly scalable, maintainable codebases using two powerful, built-in patterns: Local Modules and Nuxt Layers.

The Starting Point: The Conventional Monolith

Every standard Nuxt application starts as a monolith. This isn't a negative term; it simply means the entire frontend exists as a single, tightly-coupled unit within one codebase.

The conventional structure organizes files by their type:

/my-nuxt-app
├── components/
   ├── BlogCard.vue
   ├── UserAvatar.vue
   └── ProductDisplay.vue
├── pages/
   ├── blog/
   ├── users/
   └── products/
├── composables/
   ├── useBlog.ts
   └── useUser.ts
└── nuxt.config.ts

For a while, this works beautifully. But as the application scales, this "type-first" organization leads to high coupling and low cohesion. A developer working on the "blog" feature has to jump between components/, pages/, and composables/, and their changes might unintentionally affect the "products" feature because the code isn't properly isolated. This is where a shift in mindset is required.

The Paradigm Shift: Domain-Driven Design

To solve the scaling problem, we can adopt principles from Domain-Driven Design (DDD). Instead of organizing code by file type, we organize it by business domain or feature. In our example, the domains are "blog," "users," and "products."

The goal is to group all the code related to a single domain into one self-contained unit. This makes the codebase easier to navigate, test, and maintain, as each team can own a specific domain. Nuxt provides two primary tools to achieve this architectural separation: Local Modules and Layers.

Pattern 1: Local Modules for Feature Encapsulation

Nuxt's module system is a powerful way to extend the framework's core functionality. While we often consume community modules (like @nuxt/content), we can also create our own local modules to encapsulate features within our application.

A local module is a self-contained package within your project's modules/ directory that can register its own components, composables, and even server routes. This makes them the perfect tool for creating domain-specific packages.

How to Implement a Local Module

Let's refactor our app to use a local module for the "blog" feature.

1. Create the Module Directory: First, create a modules/ directory at the project root, and inside it, a folder for your domain, such as blog/.

2. Define the Module Entry Point: Inside modules/blog/, create a module.ts file. This is where you define what the module does using the @nuxt/kit utilities.

// modules/blog/module.ts
import { defineNuxtModule, createResolver, addComponentsDir, addImportsDir } from 'nuxt/kit'

export default defineNuxtModule({
  meta: {
    name: 'blog',
  },
  setup(options, nuxt) {
    const { resolve } = createResolver(import.meta.url)

    // 1. Add components
    addComponentsDir({
      path: resolve('./components'),
      pathPrefix: false, // No 'blog' prefix for component names
    })

    // 2. Add composables
    addImportsDir(resolve('./composables'))

    // 3. You can also extend routes, add plugins, etc. here
  },
})

3. Organize Domain-Specific Files: Now, move all files related to the blog into the modules/blog/ directory, following a familiar structure.

/my-nuxt-app
├── modules/
   └── blog/
       ├── components/
   └── BlogCard.vue
       ├── composables/
   └── useBlog.ts
       └── module.ts
└── nuxt.config.ts

4. Register the Local Module: Finally, register your new local module in nuxt.config.ts.

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['./modules/blog'],
})

With this setup, the BlogCard component and useBlog composable are still auto-imported across your application, but their source code is now neatly encapsulated within the "blog" domain. This makes the feature self-contained and easier for a dedicated team to manage.

Pattern 2: Nuxt Layers for Architectural Separation

Nuxt Layers are a higher-level architectural feature that takes the concept of modularity even further. A layer is essentially a "mini Nuxt application" that can be extended by your main project. Layers can contain their own nuxt.config.ts, as well as all the standard Nuxt directories like pages/, layouts/, and components/.

This makes layers the ideal tool for implementing Domain-Driven Design, as each domain can be structured as its own independent layer.

How to Implement Nuxt Layers

Let's continue our refactor by moving the "users" domain into its own layer.

1. Create a Layers Directory: It's a good practice to group local layers. Create a layers/ directory at the project root. Inside, create a folder for the "users" domain.

2. Structure the Layer Like a Nuxt App: The layers/users/ directory will have its own Nuxt structure, including a nuxt.config.ts file to signify that it's a layer.

/my-nuxt-app
├── layers/
   └── users/
       ├── components/
   └── UserAvatar.vue
       ├── pages/
   └── users/
       └── [id].vue
       └── nuxt.config.ts  // Can be an empty export default {}
└── nuxt.config.ts

3. Extend the Layer in the Main App: In your main nuxt.config.ts, use the extends property to include the layer.

// nuxt.config.ts
export default defineNuxtConfig({
  extends: [
    './layers/users',
  ],
  modules: [
    './modules/blog',
  ],
})

That's it. Nuxt will now merge the files and configurations from the users layer into your main application. The components will be auto-imported, and the pages will be added to the router, just as if they were in the root directory. This creates a powerful separation of concerns, allowing entire features, including their routes and UI, to be developed in isolation.

Layer Priority

When using multiple layers, it's important to understand the priority order. Nuxt merges layers, with the main project having the highest priority. If two layers define the same file (e.g., components/Button.vue), the one with the higher priority in the extends array will be used.

Modules vs. Layers: Which One to Choose?

While both modules and layers promote modularity, they serve different architectural purposes.

FeatureLocal ModulesNuxt Layers
Primary Use CaseEncapsulating reusable logic, extending Nuxt's core functionality.Architectural separation, sharing entire application slices (UI, pages, configs).
StructureRequires a module.ts entry point and uses @nuxt/kit APIs.Mirrors a standard Nuxt project structure, including a nuxt.config.ts.
Developer ExperienceRequires knowledge of the Nuxt Kit API.Feels identical to writing a standard Nuxt application.
Best ForCreating domain-specific utilities, plugins, or server routes.Implementing Domain-Driven Design, creating themes, or managing monorepos.

In short:

  • Use Local Modules when you need to programmatically interact with Nuxt's build process or encapsulate a specific, reusable piece of functionality.
  • Use Nuxt Layers when you want to split your application into larger, domain-specific vertical slices that are structured like mini Nuxt apps.

Conclusion: Building for the Future

As your Nuxt application scales, moving away from a simple monolithic structure is not just an option—it's essential for long-term maintainability and team productivity. By embracing a modular architecture with Domain-Driven Design principles, you can prevent your codebase from becoming a tangled mess.

Local Modules provide a powerful tool for encapsulating specific functionalities, while Nuxt Layers offer a higher-level, more intuitive way to structure your application into independent, domain-focused slices. By understanding the strengths of each pattern, you can architect a Nuxt application that is not only fast and efficient today but also scalable and a pleasure to work on for years to come.

A Note on Process

This blog serves as a personal learning journal where I dive into topics that capture my curiosity. To refine my understanding and the clarity of the writing, I use AI as a collaborative partner in the research and composition process.

Built with the help of AI

By Ismail Ammor ·