Modular site architecture with Nuxt layers

Build sites that scale by organising code by domain, not concern

15 minute read

Intro

Nuxt 3 introduces a new paradigm called “Layers” that the docs describe as “a powerful system that allows you to extend the default files, configs, and much more (opens new window)”. Whilst this explanation is technically accurate, the emphasis on “extending defaults” overlooks another perhaps more impactful use case – that of logically reorganising your application.

Overview

To get you up-to-speed on the concepts, I’ll begin with some theory:

Then, I’ll share actionable steps to migrate an existing Nuxt application:

  • Nuxt concerns
    How individual Nuxt concerns work or need to be reconfigured when moved to layers
  • Site migration
    Advice and steps to successfully migrate your own Nuxt 3 site to layers
  • Demo
    A demo repo with tagged commits following a layers migration

You might also want to skim the official Layers docs before continuing:

Contents

It’s a long article (!) so here’s the full table of contents if you want to skip:

Site organisation

Let’s take a look at two main ways to organise sites and apps; by concern and by domain.

Note that the choice of words “domain” and “concern” could easily be “feature” and “responsibility”.

Feel free to use whatever terms make the most sense to you.

By concern

Most Vue and Nuxt projects are born of simple starter templates, which group files by concern (pages, components, etc):

+- src
    +- components
    |   +- blog
    |   |   +- ...
    |   +- home
    |       +- ...
    +- content
    |   +- blog
    |       +- ...
    +- pages
    |   +- blog.vue
    |   +- index.vue
    +- ...

This folder structure is simple to understand and somewhat invisible when your site or application is small.

However, as sites grow in size, this grouping obfuscates more natural relationships (i.e. everything related to blog) which makes it hard to understand what your site or application actually does.

By domain

At a certain size of site (and actually, not that big!) it becomes more intuitive to silo files by domain (blog, home, etc):

+- src
    +- blog
    |   +- components
    |   |   +- ...
    |   +- content
    |   |   +- ...
    |   +- pages
    |       +- blog.vue
    +- home
    |   +- components
    |   |   +- ...
    |   +- pages
    |       +- index.vue
    +- ...

Transposing physical locations has tangible benefits…

File management:

  • domains (blog, home, etc) become self-contained units
  • related code will generally be located in a sibling folder
  • less open folders / scrolling / jumping in your IDE

Configuration and settings:

  • domain config is discrete from from global config
  • simpler, smaller, domain entry points, rather than one huge config file
  • minimal mixing of global and local concerns

Developer experience:

  • PRs are simpler as most files will exist downstream from a common folder
  • you can more easily develop new features or site sections
  • you can more easily turn complete features on / off
  • domains can be broken out further if they get too large

The conceptual shift from concern to domain may feel familiar to you if you moved (opens new window) from Vue’s Options API to the Composition API; rather than concerns being striped across a sprawling options structure, they can be more naturally grouped as composables.

Nuxt layers intro

So it turns out that Nuxt Layers are perfect to restructure and reorganise a site by domain.

Layers can be viewed as “mini” applications which are stitched together to create the “full” application.

Each folder:

  • may contain pages, components, server sub-folders, etc
  • identifies it’s a layer using a nuxt.config.ts file

A small personal site might be organised as follows:

+- src
    +- base                          <-- global, shared or one-off functionality
    |   +- ...
    +- blog                          <-- nuxt content configuration, markdown articles
    |   +- ...
    +- home                          <-- one-off components, animation plugin and configuration
    |   +- components
    |   |   +- Hero.vue
    |   |   +- Services.vue
    |   |   +- Testimonials.vue
    |   |   +- ...
    |   +- pages
    |   |   +- index.vue
    |   +- plugins
    |   |   +- ...
    |   +- nuxt.config.ts
    +- ...
    +- nuxt.config.ts 

The top-level layers silo related pages, components, plugins, even config.

Finally, the root-level nuxt.config.ts combines these layers via unjs/c12 (opens new window)’s extends keyword:

export default defineNuxtConfig({
  extends: [
    './base',
    './blog',
    './home',
  ]
})

Note that c12 can also extend from packages and repos (opens new window) – but for the sake of this article, I’m only covering folders.

Nuxt concerns

Now that you understand how a layer-based site is structured, let’s review some specifics for Nuxt’s concerns to work correctly under this new paradigm:

Note that this section is effectively a sanity check for layer-related configuration, and:

  • sets you up for the site migration section which takes you through full layers refactor
  • provides lots of tips and tricks for configuration in general

Framework folders

Layer folders

Core framework folders within layers (opens new window) are auto scanned build the full app.

Additionally, many of these entities can be further modified using config (opens new window):

Folder Config Notes
./components (opens new window) components (opens new window) Auto-imported (nested, renamed by default 🙁)
./composables (opens new window) imports (opens new window) Auto-imported (top-level only)
./layouts (opens new window) Auto-imported (nested)
./pages (opens new window) pages (opens new window) Generates routes
./plugins (opens new window) plugins (opens new window) Auto-registered (top-level only)
./public (opens new window) dir.public (opens new window) Copied to ./output
./server (opens new window) serverDir (opens new window) Adds middleware, api routes, etc
./utils (opens new window) imports (opens new window) Auto-imported (top-level only)
./nuxt.config.ts (opens new window) Config merged with root nuxt.config.ts
./app.config.ts (opens new window) Config merged with root app.config.ts

This means you can generally break out concerns across layers as you see fit – and Nuxt will take care of the loading, registering, and the splicing together of the files.

However, note that some same-named files from different layers will overwrite each other, i.e. if you have two <layer>/pages/index.vue files, then the second layer will overwrite the first.

Note: I’m going to further investigate the behaviour of overlapping core folders like public and server as I’ve had different results in different projects (probably human error!) so check back soon as I’ll document my findings.

Core folders

Nuxt’s default / global folder locations can also be moved to layers:

However, you will need to, update Nuxt’s dir (opens new window) config settings:

// src/nuxt.config.ts
export default defineNuxtConfig({
  dir: {
    // core
    assets: 'core/assets',
    modules: 'core/modules',
    middleware: 'core/middleware',
    plugins: 'core/plugins',
    
    // site
    layouts: 'layers/site/layouts',
    pages: 'layers/site/pages',
    public: 'layers/site/public',
  },
})

See Global Concerns section for rationale on tidying up your project’s root.

Programmatic options

Beyond layer folders and config, you have options to add or modify concerns programmatically.

See:

Pages and routes

Layers can happily contain their own pages and define navigable routes.

However, any pages folders must contain full folder paths – as the layer name is not automatically prepended:

+- src
    +- blog
    |   +- pages
    |       +- blog             <-- route starts here, i.e. /blog
    |           +- index.vue
    |           +- ...
    +- home
        +- pages
            +- index.vue        <-- route starts here, i.e. /

Components

Nuxt’s components auto-importing and auto-registering rules (opens new window) are IMHO unnecessarily complex and opaque – and considering this article is about helping you organise your Nuxt app at scale – I wanted to comment.

The thing is, whilst Nuxt’s default auto-import settings do scan components folders recursively:

  • top-level components import using their given names
  • nested components are prefixed with the path’s segments

As such, out-of-the-box component “auto-importing” is also component “auto-renaming”:

Folder Component name Auto-import name
components Dropdown.vue Dropdown.vue
components/form Dropdown.vue FormDropdown.vue
components/form/options Dropdown.vue FormOptionsDropdown.vue
components/form/options DropdownItem.vue FormOptionsDropdownItem.vue

This directly impacts component organisation, usage, IDE integration and refactoring, which I’ve broken down here:

Meanwhile, your options to customise Nuxt’s defaults are:

// src/nuxt.config.ts
export default defineNuxtConfig({
  components: [
    // use defaults: use path prefix
    '~/core/components',
          
    // override defaults: no path prefix
    { path: '~/layers/site/components', pathPrefix: false },

    // override defaults: no path prefix, register all globally (for Nuxt Content)
    { path: '~/layers/blog/components', pathPrefix: false, global: true },
  ]
})

Note that components config can reconfigure existing folders (useful in layers):

// src/layers/site/nuxt.config.ts
export default defineNuxtConfig({
  components: [
    { path: 'components', pathPrefix: false },
  ]
})

You can also disable component auto-import (opens new window) entirely, including any default components folder:

// root or layer nuxt.config.ts
export default defineNuxtConfig({
  components: []
})

Auto-imports

I wanted to cover so-called auto-imports (opens new window) functionality, specifically to disambiguate from components.

In Nuxt, the composables and utils folders are imported automatically, at least at the top-level (opens new window).

However, there is nothing special about the naming (as in, there is no enforcement (opens new window) of the files within) and you could (should!) add more-specifically named folders, whether-or-not you want them auto-imported. Don’t just throw arbitrary code into these folders; if it’s /services, /stores or additional /config give it a home to make the intended use clear.

To add additional folders, add them to the imports.dirs config, and decide how you want them scanned:

// src/nuxt.config.ts
export default defineNuxtConfig({
  imports: {
    dirs: [
      // add core services
      'core/services',

      // add specific files in core composables in subfolders
      'core/composables/**/*.{ts,js,mjs,mts}',

      // autoload all stores in all layers
      '**/stores'
    ]
  }
})

You can also disable any auto-importing (opens new window) but then you lose the benefit of importing the boring stuff:

export default defineNuxtConfig({
  imports: {
    autoImport: false
  }
})

A couple of other things to note about imports config:

  • it can be an array of strings (just the paths) or an object (additional options; use dirs for paths)
  • the paths format supports globs (opens new window) whereas components does not

See the path configuration section for detailed information about how Nuxt handles paths.

Nuxt Content

Nuxt Content plays nicely with Nuxt Layers.

Local sources

You can have more than one content source, meaning you can silo domain-specific content along with its related pages, components, etc. – which might suit if your site has multiple content-driven sections such as Blog, Guide, etc.:

+- src
    +- blog
    |   +- ...
    +- guide
        +- components
        |   +- ...
        +- content
        |   +- index.md
        |   +- ...
        +- pages
        |   +- ...
        +- nuxt.config.ts

Note that unlike pages you can configure content without re-nesting the folder:

// src/blog/nuxt.config.ts
export default defineNuxtConfig({
  content: {
    sources: {
      blog: {
        prefix: '/blog',
        base: './blog/content', // referenced from root
        driver: 'fs',
      }
    }
  }
})

Note that you may need to declare multiple content sources in one place if a later-added layer intends to use the / prefix, as I think the default Nuxt Content config initially sets the source to the root content folder and / prefix.

Remote sources

If you want to include content from a remote source (opens new window) such as GitHub, unjs/unstorage (opens new window) makes it possible:

// src/blog/nuxt.config.ts
export default defineNuxtConfig({
  content: {
    sources: {
      blog: {
        prefix: `/blog`,
        dir: 'content',
        repo: '<owner>/<repo>',
        branch: 'main',
        driver: 'github',
      }
    }
  }
})

For a private repositories, add your credentials (thanks to @Atinux (opens new window) and @pi0 (opens new window) for the tip (opens new window)):

export default defineNuxtConfig({
  extends: [
    ['gh:<owner>/<repo>', { giget: { auth: process.env.GH_TOKEN }}]
  ]
})

Remember to add your token (opens new window) to your project’s .env file or CI settings like so:

# .env
GH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Content components

Bonus component tip: you don’t have to use the suggested global components content folder (opens new window) to make components accessible from within Markdown documents, you could also:

  • configure any component folder as global using the components config global flag
  • mark specific components as global by renaming them with the .global.vue suffix

Tailwind

At the time of writing, Nuxt’s Tailwind module (opens new window) does not pick up layers (though it’s a simple (opens new window) PR).

Note: @atinux (opens new window) tells me this is not the case; I’ll investigate and update this section in due course

But you can easily tell Tailwind where your CSS classes can be found:

// tailwind.config.ts
export default {
  content: [
    './core/components/**/*.vue',
    './layers/**/pages/**/*.vue',
    './layers/**/components/**/*.vue',
    ...
	],
  ...
}

Config

There are a few things to think about regarding config:

  • where to locate each file
  • what each file should contain
  • how to correctly resolve paths
  • keeping code clean (see global concerns and tips)

Layer configs

Layers allow you to move domain-specific config to the individual layer config files:

// src/blog/nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    'markdown-tools'
  ],
  markdownTools: {
    ...
  }
})

This can be great for isolating domain specific functionality, and at the same time simplifying your root config.

Your final config will be intelligently-merged (via unjs/defu (opens new window)).

Path resolution

Note that path resolution in layers can be tricky, because of context, targets and formats:

export default {
  foo: resolve('../some-folder'),
  bar: 'some-layer/some-folder',
  baz: '~/other-layer',
  qux: './other-layer',
}

See the path configuration section in the site migration section for a full breakdown of the options.

Imports and exports

Given that layers are generally self-contained, importing is simplified:

// src/dashboard/components/User.vue
import { queryUser } from '../services'

If you want to import from another layer (and you opted for a flat layer structure) you essentially get aliases for free:

// src/profile/components/User.ts
import { queryUser } from '~/dashboard/services'

Otherwise, you can set up aliases (opens new window) manually:

// src/layers/profile/components/User.ts
import { queryUser } from '#dashboard/services'

If you want to expose only certain dependencies from a layer, consider an index file:

// src/dashboard/index.ts
export * from './services/foo'
export * from './utils/bar'
// src/profile/components/User.ts
import { queryUser } from '~/dashboard'

However, note that Vite’s documentation advises against (opens new window) this. There seem to be good reasons (based on the way Vite transforms inputs) but you would need to read the full linked issue thread to understand the reasons. YMMV.

And regarding auto-imports (opens new window) – remember they only import components, composables and utils folders.

You may need to configure additional imports using config.imports (opens new window) or config.components (opens new window).

Site migration

So you now understand the concepts, you have an idea of the updates to make, but you need a plan to do it.

Below, I’ve outlined my best advice, including:

Folder structure

The first thing to decide when migrating your site to layers is your ideal folder structure.

You can move some or all concerns to layers:

Partial Hybrid Flat
+- src
    +- assets
    |
    +- layers
    |   +- blog
    |   |   +- ...
    |   +- home
    |       +- ...
    |
    +- layouts
    +- plugins
    +- components
    +- nuxt.config.ts
+- src
    +- core
    |   +- ...
    |
    +- layers
    |   +- blog
    |   |   +- ...
    |   +- home
    |       +- ...
    |
    +- nuxt.config.ts
+- src
    +- blog
    |   +- ...
    +- core
    |   +- ...
    +- home
    |   +- ...
    |
    +- nuxt.config.ts

I prefer the flat or hybrid structure, as it significantly de-clutters the project outline.

Global concerns

Folders

As outlined above, you might consider moving infrequently-accessed concerns to a base or core layer:

+- src
    +- core
    |   +- middleware
    |   +- modules
    |   +- plugins
    |   +- utils
    +- ... 

If a concern spans multiple domains, or isn’t specific enough to get its own domain, site feels like a nice bucket:

+- src
    +- ... 
    +- site
        +- assets
        |   +- ...
        +- components
        |   +- Footer.vue
        |   +- Header.vue
        +- pages
        |   +- about.vue
        |   +- contact.vue
        +- public
        |   +- ...
        +- ...

Config

Moving infrequently-accessed config to layers makes it easier to get and stay organised (see tips for more suggestions!):

// src/core/nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: { ... },
  modules: [ ... ],
  plugins: [ ... ],
  nitro: { ... },
  ...
})

Note that if you move default folders you will need to reconfigure Nuxt’s dir config.

Path configuration

The correct path configurations (target and format) are critical to Nuxt locating refactored layer-based concerns.

Nuxt’s path config options can be driven by a variety of path formats:

Type Code Notes
Absolute Path.resolve('layers/some-layer') You can also use import.meta.url
Root-relative layers/some-layer
Layer-relative some-folder Relative to some-layer/nuxt.config.ts
Alias ~/layers/some-layer Expands internally to absolute path
Glob some-layer/**/*.vue Expands to an array of paths

Additionally, some config options scan nested folders, providing glob-like functionality.

Here’s a sample of the differences between some of 25+ path-related (opens new window) config options (along with their quirks):

Name Abs Root-rel Layer-rel Alias Nest Glob Notes
extends (opens new window) Layers can be nested (mainly useful for modules)
dir.* (opens new window)
dir.public (opens new window) First public folder found wins
imports.dirs (opens new window)
components (opens new window) Components are prefixed by default (based on path)
modules (opens new window)
plugins (opens new window)
ignore (opens new window)
css (opens new window) Seems to only support ~ (no alias aliases)

Advice on configuring paths

There is also the question of where to configure your paths; in the root and/or layer configuration?

I think for smaller sites, it’s fine to configure paths in the layer config.

But for larger sites, I’ve come to the conclusion that it’s just simpler to configure all path-related config in the root:

  • you’re not searching through multiple folders and layer config files
  • it’s easier to compare and copy/paste paths between options
  • path resolution is consistent between layers of differing depths
  • you limit any repetition or duplication to a single file

As such, your core nuxt.config.ts file might look something like this:

import { resolve } from 'pathe'

export default definedNuxtConfig({
  extends: [
    'core',
    'layers/blog',
    'layers/site'
  ],

  alias: {
    '#core': resolve('core'),
    '#blog': resolve('layers/blog'),
    '#site': resolve('layers/site'),
  },

  dir: {
    assets: 'core/assets',
    modules: 'core/modules',
    middleware: 'core/middleware',
    public: 'layers/site/public',
  },

  components: [
    { path: '~/layers/site/components', pathPrefix: false }, // disable path-prefixing
  ]
})

Although this looks a little repetitive and verbose – it is much easier to debug with paths in one place.

To simplify and have the correct config generated automatically, use Nuxt Layers Utils (opens new window):

{
  extends: layers.extends(),
  alias: layers.alias(),
  ...
}

See the Tips section for a full example.

Migration steps

Overview

Migrating an existing site isn’t difficult, but it can be a little risky and frustrating.

You should treat it like any other major refactor and aim to go slow; migrate feature-by-feature, folder-by-folder, or file-by-file – as your build will break – and there will be times when you don’t know why.

Set aside a few hours for a small site, and a day or more for a larger, in-production one.

You can review the demo at the end for a real-world example

Steps

Before you start:

  • review key Nuxt concerns to get a good overview of each
  • create a migration branch to isolate your updates from your working code

Make a plan to work on:

  • global concerns, such as base
  • specific domains, such as blog, home, etc

To start:

  • create aliases for all layers
  • use an IDE like Webstorm which rewrites your paths as you move files
  • run the app in dev mode, so you can see when changes break the app

Then, tackle a single domain / layer at a time:

  • create the new layer:
    • add a top-level folder
    • add the nuxt.config.ts
    • update the root alias hash (so that moves are rewritten using aliases)
    • update the root extends array
  • move concerns so that you’re only likely to break one thing at a time:
    • Config:
      • moving settings and modules should be straightforward
      • some configuration (i.e. dir, content) may need reconfiguring
    • Pages:
      • remember routes are not prefixed with the layer name
      • check file imports as you move
    • Components:
      • if imported, review paths
      • if auto-imported, should just work (unless components themselves moved to sub-folders!)
      • if not, you may need to add specific components folder paths to the components config
    • Content
      • decide whether Nuxt Content will be global or local
      • remember Nuxt Content components need to be global, so
  • the things to check as you move are:
    • Paths:
      • remember Path.resolve()'s context whilst consuming config is your project’s root folder
      • layer-level paths may still need to be ./<layer>/<concern> vs ./<concern>
    • Imports:
      • global imports may flip from ~/<concern>/<domain> to ~/<layer>/<concern>
      • local imports may become ../<concern>
    • Config imports:
      • config import statements cannot use path aliases; you may need to use ../layer/concern

Points to think about

As you make changes:

  • restart the dev server often
  • manually check related pages, components, modules, plugins, etc
  • commit your changes after each successful update or set of updates

When errors occur:

  • it may not be immediately clear why or where the error happened (i.e. Nuxt, Vite, etc)
  • make sure to properly read and try to understand terminal and browser console errors
  • if you find later something is broken, go back through your commits until you find the bug

Gotchas:

  • layer config watching is buggy (intermittent at best)
  • restart the dev server for missing pages, components, errors
  • missing components don’t error in the browser (update components config)

Tips

Use Nuxt Layers Utils

To simplify path-related configuration, use Nuxt Layers Utils (opens new window) to declare your layers once then auto-generate config:

// /<your-project>/nuxt.config.ts
import { useLayers } from 'nuxt-layers-utils'

const layers = useLayers(__dirname, {
  core: 'core',
  blog: 'layers/blog',
  site: 'layers/site',
})

export default defineNuxtConfig({
  extends: layers.extends(),
  alias: layers.alias('#'),
  ...
})

Lean on unjs/defu (opens new window) to configure smaller subsets of related options, then merge them together on export:

// src/core/nuxt.config.ts
const config = defineNuxtConfig({ ... })
const modules = defineNuxtConfig({ ... })
const build = defineNuxtConfig({ ... })
const ui = defineNuxtConfig({ ... })

export default defu(
  config,
  modules,
  build,
  ui,
)

For a complete example, check the demo’s core config (opens new window).

Consider layer helpers

For complex configuration that may differ only slightly across layers (such as hooks (opens new window)) you might consider helpers:

// src/base/utils/layers.ts
export function defineLayerConfig (path: string, options?: LayerOptions) {
  const output: ReturnType<typeof defineNuxtConfig> = {}
  if (options.hooks) { ... }
  if (options.thing) { ... }
  return output
}

Call from layers like so:

// src/blog/nuxt.config.ts
import { defineLayerConfig } from '../base/utils/layers'

export default defineNuxtConfig ({
  ...defineLayerConfig(__dirname, {
    hooks: [ 'foo', 'bar'],
    thing: true 
  })
})

Note that you cannot use path aliases such as ~ in config import statements – because Nuxt will not yet have compiled them into its own.nuxt/tsconfig.json file.

Isolate layers

Use comments or conditionals to toggle layers:

// src/nuxt.config.ts
export default defineNuxtConfig({
  extends: [
    './base',
    // './home',
    isDev && './blog',
  ]
})

Nuxt 2 users

You can use Nuxt Areas to get layers-like functionality in Nuxt 2:

Demo

So that’s a lot of theory; how about some code?

Well, I’ve taken Sébastian Chopin’s Alpine (opens new window) demo and migrated it from a concern-based to a domain-based setup.

The idea is to demonstrate a real-world migration using the actual advice given above.

The tagged milestones (opens new window) in this migration are / will be:

You can clone or browse the repo from here:

Resources

In the interest of completeness, here are some links to other resources worth looking at:

Also, various posts referring to this post, some of which have useful comments or discussion:

Last words

Hopefully this article gives you some solid ideas on how to modularise your site or app – and if I’ve skipped over anything – ideas on how to approach it. Layers are generally quite logical and predicable, with a minor tradeoff of a little more configuration.

FWIW I have a bit of a love/hate relationship with Nuxt, so if you think some of this is wrong or inaccurate, do please drop a comment and I can update the article accordingly.

And lastly, kudos to the UnJS and Nuxt team for the work they’ve done 🙏.

So...

I hope you found this article useful or enjoyable.

If you want to engage further, follow me on Twitter, Bluesky, or drop a comment or reaction below.

Either way, thanks for reading!