In recent years, the Monorepo architecture has been increasingly adopted by many projects. During the process of refactoring an old large-scale Vue project using Nuxt 3, I found Nuxt 3 to be extremely suitable for developing large-scale projects, with a strong focus on Developer Experience (DX) and being very friendly to Monorepo setups. Here, I share some of my experiences and delve deeper into the details.
This article is suitable for readers who already have a basic understanding of Vue and Nuxt 3.
When designing the architecture, I prioritized the Developer Experience (DX) and aimed to achieve characteristics that are concise, highly reusable, and flexible for expansion.
.ts
or .vue
modules from Monorepo packages and support HMR (Hot Module Replacement), eliminating the need for recompilation after every change.env
files at the Monorepo root while supporting exclusive .env
files in individual packagesGiven the variety of Monorepo tools available in the market, such as Turborepo, NX, Bit, etc., choosing a tool that meets our needs and is easy to use is crucial. I ultimately chose pnpm workspace, which not only reduced the burden of learning a new tool but also perfectly matched the needs of Monorepo.
Nuxt 3 offers the powerful feature of Layers, especially suitable for use in a Monorepo structure. Layers provide great flexibility. In Nuxt 3, each Layer can be an independent Nuxt application, allowing direct use of Nuxt's Directory Structure when developing different Layers.
Interestingly, you can let a Nuxt app directly extend (inherits) one or more Nuxt Layers. For instance, you might create a UI
Layer and then simply add a line of extends
code in your App
Layer's configuration to freely use all components (including components
, composables
, etc.) from the UI
Layer. Moreover, you can even override any config from the UI
Layer in the App
Layer.
When you need powerful features like those in a Nuxt Module (e.g., using hook
or addComponent
), you can directly write a Nuxt Module in the Layer, further enhancing its flexibility and functionality.
To start setting up a Monorepo environment, you first need to configure a pnpm workspace. Here are the basic steps:
pnpm init
command or manually create a package.json
file.packages
.pnpm-workspace.yaml
file and set the folder for placing packages as packages/*
.Tip: You can directly click on the file names in the interactive interface below to view the contents of each file.
packages:
- packages/*
Now, create a @nuxt-monorepo/ui
Nuxt Layer package, which can be inherited and used by other different Nuxt packages.
Create a subfolder named ui
inside the packages
folder and configure a basic Nuxt project following the example below:
{
"name": "@nuxt-monorepo/ui",
"private": true,
"type": "module",
"main": "./nuxt.config.ts",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"nuxt": "^3.8.2",
"vue": "^3.3.10",
"vue-router": "^4.2.5"
}
}
shamefully-hoist
setting in the root .npmrc
file changes the behavior of pnpm during install
across the entire Monorepo.ui/components
folder contains all the ui
components, as per Nuxt's Directory Structure.ui/nuxt.config.ts
can be inherited (extended) by other Nuxt Apps/Layers.ui/tsconfig.json
is used to extend .nuxt
's tsconfig.json
.ui/package.json
needs to set main
to ensure the Layer can be correctly inherited:
"main": "./nuxt.config.ts"
You can also choose to use the Nuxt Layer's Starter Template to create a Layer. Note that in this Monorepo structure, the typescript.includeWorkspace
setting in the Starter Template should be disabled to avoid errors during Type Checks.
After successfully creating a UI Layer, the next step is to create a Nuxt App that inherits from the UI Nuxt Layer.
packages
directory, use the nuxi
command to create a Nuxt project named app
.cd packages
pnpm dlx nuxi@latest init app
app
package to @nuxt-monorepo/app
.- "name": "nuxt-app",
+ "name": "@nuxt-monorepo/app",
app
directory and run pnpm install to initialize the project.cd app
pnpm i
app
directory, add the recently created @nuxt-monorepo/ui
as a devDependency
.pnpm add -D @nuxt-monorepo/ui
app
's nuxt.config.ts
, add @nuxt-monorepo/ui
as a Layer.export default defineNuxtConfig({
devtools: { enabled: true },
extends: ["@nuxt-monorepo/ui"]
})
ui
's components/TheMessage
in app.vue
. Now your app
project should look like this:<template>
<div>
<TheMessage />
</div>
</template>
After completing these settings, your app
now inherits from the ui
Layer. You can use any component from ui
at any time through Nuxt's Auto-imports feature.
After running the app
, you should see the ui
's TheMessage
component successfully displayed in the app
.
# packages/app
pnpm dev
If you want to execute a script (like build
) across all packages in the Monorepo at once, you can use pnpm's --recursive (-r)
.
{
"name": "nuxt-monorepo",
"scripts": {
"build": "pnpm -r build"
}
}
When you run pnpm build
in the Monorepo's root directory, it will automatically execute the build
command in ui
, app
, and all other packages containing a build
script.
Additionally, you can customize the packages for batch command execution using --filter (-F)
.
When developing with a Monorepo structure, there are often many configurations that need to be shared. Below, I share some common configurations and how to share them between different packages.
Nuxt Tailwind is an official Nuxt Module developed by Nuxt, providing good support for integration in Nuxt Layers.
You just need to perform basic Tailwind CSS setup in the ui
Layer, and app
can directly use these settings. Moreover, any tailwind.config.js
added in app
will automatically merge with ui
's configuration.
module.exports = {
theme: {
colors: {
'app': 'red'
}
}
}
Example: nuxt-monorepo/tailwindcss
If you want to use the ui
's UnoCSS settings in app
, it's recommended to define an UnoCSS preset in ui
and then add ui
to the presets of other packages. (Refer to discussion UnoCSS #2430)
import {
defineConfig,
transformerDirectives
} from 'unocss';
import uiPreset from '@nuxt-monorepo/ui/preset';
export default defineConfig({
presets: [uiPreset],
transformers: [transformerDirectives()],
theme: {
colors: {
'app': 'red'
}
}
});
Note that the preset should not contain Transformers, so these need to be added separately in each UnoCSS config.
Example: nuxt-monorepo/unocss
Nuxt has built-in Autoprefixer, so if you want to share Browserslist settings among multiple Nuxt Apps, you can place a .browserslistrc
file in the Monorepo's root directory. This way, when running or compiling in app
, the system will automatically find and apply the settings from the parent directory.
defaults
not ie > 0
not ie_mob > 0
If you have Browserslist settings in both a package and the Monorepo root, the package's settings will take precedence.
.env
Environment VariablesIn Nuxt, by default, the .env
file in the project directory is read. However, this can cause some problems in a Monorepo structure.
Take app
as an example: when running Nuxt in the app
directory, the system will only read app/.env
and ignore ui/.env
and any .env
in all parent directories.
This means that if you plan to place a shared .env
file in the Monorepo's root directory, some additional configuration is necessary.
.env
with dotenv-cliIn a Monorepo environment, dotenv-cli can load .env
files into environment variables before executing commands. Here are the steps to use dotenv-cli in your project:
Install dotenv-cli in the Monorepo's root directory.
# project root
pnpm i -D -w dotenv-cli
Create a .env
file in the root directory and define the required environment variables.
echo "NUXT_PUBLIC_FOO=bar" > .env
Use dotenv-cli to load the .env
file from the root directory into the environment variables and then execute the app
's command.
pnpm dotenv -- pnpm --filter @nuxt-monorepo/app dev
For convenience, you can add some commands to the scripts
in package.json
.
{
"name": "nuxt-monorepo",
"scripts": {
"packages": "dotenv -- pnpm --filter",
"app": "pnpm packages @nuxt-monorepo/app"
},
"devDependencies": {
"dotenv-cli": "^7.3.0"
}
}
This way, you can achieve the same effect with shorter commands in the Monorepo's root directory:
pnpm app dev
# or
# pnpm packages app dev
# or
# pnpm packages @nuxt-monorepo/app dev
.env
OnlyAfter installing dotenv-cli, if you want to ignore the root .env
and only use app/.env
, you just need to run commands in the app
directory as usual, without any additional configuration.
cd packages/app
# run app dev with app/.env
pnpm dev
--dotenv
Apart from using dotenv-cli, Nuxt offers a --dotenv
flag, allowing you to specify a particular .env
file to be read when executing dev
or build
commands.
The advantage of this solution is that you don't need to install additional packages. However, the downside is that you need to add --dotenv ../../.env
in each script
. This not only uses relative paths, which goes against our design goal of 'dependency management', but also lacks the flexibility to switch between reading the .env
in the project directory and the .env
in the monorepo root directory. Therefore, I don't recommend using this approach in this monorepo architecture.
When using this monorepo architecture and Nuxt Layer for development, there are some tricks you need to be aware of to align with our architectural design goals.
When developing web applications, we often use Path Alias to avoid using less intuitive relative paths. However, this practice requires special attention in our Monorepo environment.
When using Nuxt's default Root Path Alias (like ~/
or @/
) in the ui
Layer, although commands like nuxt dev
or build
work normally in app
, there are issues during TypeScript type checking.
<script setup lang="ts">
import { message } from '~/constants/message'
// ^ type check error
</script>
<template>
<div>
Component from ui {{ message }}
</div>
</template>
This issue arises because the TypeScript compiler (tsc
) reads the tsconfig.json
settings from app
. For instance, if you try to import ~/constants/message
in ui
, intending to point to ui/constants/message.ts
, running tsc
in app
mistakenly resolves the path to app/constants/message.ts
.
Nuxt successfully compiles because it resolves these Path Aliases on its own. Also, nuxt build
by default does not perform Type Check, so no errors appear during the build process.
Notably, VS Code TypeScript can also resolve these paths on its own, so no error prompts appear in the IDE.
If you are not concerned about passing Type Check, you can continue using Nuxt's default Root Path Alias in your Layer.
Conversely, if Type Check is important for your project, you can define a dedicated Root Path Alias using Nuxt's alias
config.
import { createResolver } from '@nuxt/kit'
const { resolve } = createResolver(import.meta.url)
export default defineNuxtConfig({
alias: { '~ui': resolve('./') }
})
After setting the custom Root Path Alias to ~ui
, you can use it in ui
as follows:
import { message } from '~ui/constants/message'
I am also currently researching methods that can accommodate both the default Root Path Alias ~/
and Type Check, by modifying vue-tsc or developing a Plugin to properly resolve paths. As Volar.js and vue-tsc are undergoing significant refactoring, I plan to implement a solution once they stabilize. Meanwhile, I have created an Issue for this problem in Nuxt, which you are welcome to follow.
As the number of packages and Layers in a monorepo increases, identifying the source of components from different packages can become challenging.
Take ui/TheMessage
as an example. When using <TheMessage>
in app
, it's not immediately clear whether this component is from ui
or app
itself.
We can use Nuxt's components prefix
setting to uniformly prefix all ui
components with a custom prefix.
import { createResolver } from '@nuxt/kit'
const { resolve } = createResolver(import.meta.url)
export default defineNuxtConfig({
alias: { '~ui': resolve('./') },
components: [
{ path: '~ui/components', prefix: 'Ui' }
]
})
Thus, ui
's <TheMessage>
automatically becomes <UiTheMessage>
, allowing quick identification of the component's origin when using ui
components in app
.
<template>
<div>
<UiTheMessage />
</div>
</template>
Nuxt's Auto-imports cannot automatically load Custom Directives. Here are two solutions:
.vue
files<script setup lang="ts">
import { vTest } from '#imports'
</script>
<template>
<div v-test />
</template>
<script>
Nuxt's Auto-imports only automatically load components in <template>
. If you want to reference a component in <script>
, you need to manually import it.
<script setup lang="ts">
import { UiTheMessage } from '#components'
const theMessage = ref<InstanceType<typeof UiTheMessage> | null>()
</script>
<template>
<div>
<UiTheMessage refs="theMessage" />
</div>
</template>
After refactoring the project with Nuxt, a significant reduction in import
code is achieved, and the standardized directory structure enables more efficient file categorization and usage.
Whether your Vue project is SSR, SSG, or a simple SPA, Nuxt can effectively meet all requirements, with the Nuxt Layer being an almost perfect Vue Monorepo solution.
The Monorepo architecture mentioned in this article is not just a proof of concept but has been applied in real products. Although it may not meet everyone's needs, I hope it can inspire developers working with Vue and Nuxt.
If you scrolled here for the project code without finishing the article, congratulations on finding it! Feel free to refer back to the article if you have any questions while reviewing the code.
Source code: serkodev/nuxt-monorepo ⭐️