
Modular site architecture with Nuxt layers
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". 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
Site organisation
Let's take a look at two main ways to organise sites and apps; by concern and by domain.
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 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,serversub-folders, etc - identifies it's a layer using a
nuxt.config.tsfile
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.tsThe top-level layers silo related pages, components, plugins, even config.
Finally, the root-level nuxt.config.ts combines these layers via unjs/c12's extends keyword:
export default defineNuxtConfig({
extends: [
'./base',
'./blog',
'./home',
]
})Some additional notes:
- you can use relative or absolute paths, but not aliases, as Nuxt has not yet run so won't be able to resolve them
- c12 can also extend from packages and repos – but for the sake of this article, I'm only covering folders
Nuxt concerns
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
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
Framework folders
Layer folders
Core framework folders within layers are auto scanned build the full app.
Additionally, many of these entities can be further modified using config:
| Folder | Config | Notes | |
|---|---|---|---|
./components | components | Auto-imported (nested, renamed by default 🙁) | |
./composables | imports | Auto-imported (top-level only) | |
./layouts | Auto-imported (nested) | ||
./pages | pages | Generates routes | |
./plugins | plugins | Auto-registered (top-level only) | |
./public | dir.public | Copied to ./output | |
./server | serverDir | Adds middleware, api routes, etc | |
./utils | imports | Auto-imported (top-level only) | |
./nuxt.config.ts | Config merged with root nuxt.config.ts | ||
./app.config.ts | 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.
It's possible I haven't properly investigated the behaviour of overlapping core folders like public and server as I've had different results in different projects (probably human error) but I will look to check again and 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 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 for full layers information including support in modules
- Module Author Guide for examples of adding and modifying resources through code
- Nuxt Kit which provides a set of utilities to help you create and use modules
- Lifecycle Hooks which allow you to hook into the 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 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 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 functionality, specifically to disambiguate from components.
In Nuxt, the composables and utils folders are imported automatically, at least at the top-level.
However, there is nothing special about the naming (as in, there is no enforcement 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.
Configuring auto-imports
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 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
arrayofstrings(just the paths) or anobject(additional options; usedirsfor paths) - the paths format supports globs whereas
componentsdoes not
See the path configuration section for detailed information about how Nuxt handles paths.
Debugging auto-imports
And some final notes on debugging:
- if you're having issues with relative paths, try using absolute paths instead
- if you use relative paths in a layers
importsconfig, you may need to use an absoluteextendspath in root config - if auto-imports really don't seem to get picked up:
- delete your
.nuxtfolder and restart thedevserver - restart the TypeScript language server in your IDE
- check that another layer or root config hasn't disabled auto-imports
- delete your
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.tsNote 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 such as GitHub, unjs/unstorage 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 private repositories, add your credentials (thanks to @Atinux and @pi0 for the tip):
export default defineNuxtConfig({
extends: [
['gh:<owner>/<repo>', { giget: { auth: process.env.GH_TOKEN }}]
]
})Remember to add your token to your project's .env file or CI settings like so:
# .env
GH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxContent components
Bonus component tip: you don't have to use the suggested global components content folder to make components accessible from within Markdown documents, you could also:
- configure any component folder as global using the components config
globalflag - mark specific components as global by renaming them with the
.global.vuesuffix
Tailwind
At the time of writing, Nuxt's Tailwind module does not pick up layers (though it's a simple PR).
@atinux 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).
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 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 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 – remember they only import components, composables and utils folders.
You may need to configure additional imports using config.imports or config.components.
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:
+- 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.tsI 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 config options (along with their quirks):
| Name | Abs | Root-rel | Layer-rel | Alias | Nest | Glob | Notes |
|---|---|---|---|---|---|---|---|
extends | ● | ● | ● | Layers can be nested (mainly useful for modules) | |||
dir.* | ● | ● | |||||
dir.public | ● | ● | First public folder found wins | ||||
imports.dirs | ● | ● | ● | ● | ● | See note above about absolute or relative paths | |
components | ● | ● | ● | ● | ● | Components are prefixed by default (based on path) | |
modules | ● | ||||||
plugins | ● | ● | ● | ||||
ignore | ● | ||||||
css | ● | ● | ● | 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:
{
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
migrationbranch 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
devmode, 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
aliashash (so that moves are rewritten using aliases) - update the root
extendsarray
- 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
componentsconfig
- 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
globalflag
- add them to
- the things to check as you move are:
- Paths:
- remember
Path.resolve()'s context whilst consumingconfigis 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
importstatements 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
componentsconfig)
Tips
Use Nuxt Layers Utils
To simplify path-related configuration, use Nuxt Layers Utils 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 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.
Consider layer helpers
For complex configuration that may differ only slightly across layers (such as hooks) 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
})
})You cannot use path aliases such as ~ in config import statements!
The reason for this is that 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 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 in this migration are / will be:
0.1.0– Alpine starter repo
Local content extending external theme0.5.0– Combined theme and content
Local content and theme, with a traditional flat folder structure (by concern)1.0.0– Refactor to flat layers
Repackage to core, site and articles layers (by domain)1.1.0– Refactor layers to subfolder
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
Broad introduction to layers from Krutie Patel's talk at Nuxt Nation 2023 - Nuxt Monorepo for Large-Scale Vue Web Application
In-depth article by SerKo on using a monorepo and layers to build Nuxt apps - Nuxt 3 monorepo example -- Basic example
Simpler example of how to get started with a Nuxt 3 layers monorepo - How to structure Vue projects
Great article on different ways to structure projects, with an intro to Feature-Sliced Design - Authoring Nuxt Layers
Nuxt's own documentation regarding authoring layers - Google search
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 🙏.
export default defineNuxtConfig({
imports: {
autoImport: false
}
})