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
--dotenvApart 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 ⭐️