Modular site architecture with Nuxt layers
Build sites that scale by organising code by domain, not concern
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:
- Site organisation
A comparison of organising by concern vs by domain - Nuxt layers intro
A brief intro to Nuxt layers and how they work
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:
- Framework folders
- Pages and routes
- Components
- Auto-imports
- Nuxt Content
- Tailwind
- Config
- Imports and exports
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
andserver
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:
/assets
(opens new window)/layouts
(opens new window)/middleware
(opens new window)/modules
(opens new window)/pages
(opens new window)/plugins
(opens new window)/public
(opens new window)
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:
- Authoring Nuxt Layers (opens new window) for full layers information including support in modules
- Module Author Guide (opens new window) for examples of adding and modifying resources through code
- Nuxt Kit (opens new window) which provides a set of utilities to help you create and use modules
- Lifecycle Hooks (opens new window) which allow you to hook into teh application build and runtime process
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
ofstrings
(just the paths) or anobject
(additional options; usedirs
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 |
---|---|---|
|
|
|
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.
A review of Nuxt’s path-related config
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:
- 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
- add them to
components/content
, or - register them separately using the
global
flag
- add them to
- the things to check as you move are:
- Paths:
- remember
Path.resolve()
's context whilst consumingconfig
is your project’s root folder - layer-level paths may still need to be
./<layer>/<concern>
vs./<concern>
- remember
- Imports:
- global imports may flip from
~/<concern>/<domain>
to~/<layer>/<concern>
- local imports may become
../<concern>
- global imports may flip from
- Config imports:
- config
import
statements cannot use path aliases; you may need to use../layer/concern
- config
- Paths:
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('#'),
...
})
Group related config
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 configimport
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:
0.1.0
– Alpine starter repo (opens new window)
Local content extending external theme0.5.0
– Combined theme and content (opens new window)
Local content and theme, with a traditional flat folder structure (by concern)1.0.0
– Refactor to flat layers (opens new window)
Repackage to core, site and articles layers (by domain)1.1.0
– Refactor layers to subfolder (opens new window)
Move site and articles to sub-folder (by domain, but neater)1.2.0
– Refactor using Nuxt Layers Utils (WIP)
Migrate path configuration to root (by domain, but simpler)1.3.0
– Advanced layer functionality (WIP)
Push layers to see how far we can go!
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:
- Nuxt Layers Unwrapped (opens new window)
Broad introduction to layers from Krutie Patel (opens new window)’s talk at Nuxt Nation 2023 - Nuxt Monorepo for Large-Scale Vue Web Application (opens new window)
In-depth article by SerKo (opens new window) on using a monorepo and layers to build Nuxt apps - Nuxt 3 monorepo example – Basic example (opens new window)
Simpler example of how to get started with a Nuxt 3 layers monorepo - How to structure Vue projects (opens new window)
Great article on different ways to structure projects, with an intro to Feature-Sliced Design (opens new window) - Authoring Nuxt Layers (opens new window)
Nuxt’s own documentation regarding authoring layers - Google search (opens new window)
Google’s results for “nuxt layers”
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 🙏.