Nuxt Monorepo for Large-Scale Vue Web Application

2023-12-23

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.

Architectural Design Goals

When designing the architecture, I prioritized the Developer Experience (DX) and aimed to achieve characteristics that are concise, highly reusable, and flexible for expansion.

Project Architecture

  • Support for Multi-Application Development: Enables the simultaneous development of multiple web applications
  • Shared Resources: Allows sharing of UI libraries and other packages (like API clients) between web applications
  • Dependency Management: Import between Monorepo packages using package names, avoiding relative paths

Development

  • Simplify Creation of New Packages: Reduce the complexity of configuring new Monorepo packages
  • Independence: Ensure each package within the Monorepo can be developed, run, tested, and released independently
  • Avoid Frequent Compilation: Support direct import of shared .ts or .vue modules from Monorepo packages and support HMR (Hot Module Replacement), eliminating the need for recompilation after every change

Build and Publish

  • Shared and Independent Environment Configuration: Allow sharing .env files at the Monorepo root while supporting exclusive .env files in individual packages
  • Optimized Build: Ensure Tree Shaking is supported when packages within the Monorepo import each other
  • Type Checking: Ensure passing Type Checks

Choosing the Right Tools

pnpm workspace

Given 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 Layers

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.


Building a Monorepo

Setting Up pnpm Workspace

To start setting up a Monorepo environment, you first need to configure a pnpm workspace. Here are the basic steps:

  1. Use the pnpm init command or manually create a package.json file.
  2. Create an empty folder named packages.
  3. Create a 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/*

Creating a UI Layer

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"
  }
}
  • The shamefully-hoist setting in the root .npmrc file changes the behavior of pnpm during install across the entire Monorepo.
  • The ui/components folder contains all the ui components, as per Nuxt's Directory Structure.
  • The settings in 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.

Creating an App

After successfully creating a UI Layer, the next step is to create a Nuxt App that inherits from the UI Nuxt Layer.

  1. In the packages directory, use the nuxi command to create a Nuxt project named app.
cd packages
pnpm dlx nuxi@latest init app
  1. For consistency, rename the app package to @nuxt-monorepo/app.
packages/app/package.json
- "name": "nuxt-app",
+ "name": "@nuxt-monorepo/app",
  1. Enter the app directory and run pnpm install to initialize the project.
cd app
pnpm i

Adding the UI Layer to the App

  1. In the app directory, add the recently created @nuxt-monorepo/ui as a devDependency.
pnpm add -D @nuxt-monorepo/ui
  1. In the app's nuxt.config.ts, add @nuxt-monorepo/ui as a Layer.
packages/app/nuxt.config.ts
export default defineNuxtConfig({
  devtools: { enabled: true },
  extends: ["@nuxt-monorepo/ui"]
})
  1. Try using 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.

Running the App

After running the app, you should see the ui's TheMessage component successfully displayed in the app.

# packages/app
pnpm dev
Component from ui

Running scripts across packages

If you want to execute a script (like build) across all packages in the Monorepo at once, you can use pnpm's --recursive (-r).

package.json
{
  "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).


Config Sharing

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.


Sharing Tailwind CSS Config

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


Sharing UnoCSS Config

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


Sharing Autoprefixer Config

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.


Sharing .env Environment Variables

In Nuxt, by default, the .env file in the project directory is read. However, this can cause some problems in a Monorepo structure.

Limitations of Default Behavior

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.

Managing .env with dotenv-cli

In 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
Simplifying Execution Commands

For convenience, you can add some commands to the scripts in package.json.

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
Using Project .env Only

After 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

Pros and Cons of Using --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.


Development Tips

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.


Using Path Alias in Layers

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.

Issues with Nuxt's Default Root Path Alias

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.

Why Does Nuxt Compile Successfully?

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.

Strategy for Handling This

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.

packages/ui/nuxt.config.ts
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.


Using Component Prefix

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.

packages/ui/nuxt.config.ts
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.

packages/app/app.vue
<template>
    <div>
        <UiTheMessage />
    </div>
</template>

Using Custom Directives

Nuxt's Auto-imports cannot automatically load Custom Directives. Here are two solutions:

  • Register a custom Vue directive in a plugin (from Nuxt documentation)
  • Manually import in .vue files
packages/ui/components/TheTestDirective.vue
<script setup lang="ts">
import { vTest } from '#imports'
</script>

<template>
    <div v-test />
</template>

Using Component in <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.

packages/app/app.vue
<script setup lang="ts">
import { UiTheMessage } from '#components'

const theMessage = ref<InstanceType<typeof UiTheMessage> | null>()
</script>

<template>
  <div>
    <UiTheMessage refs="theMessage" />
  </div>
</template>

Summary

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

🎉  Thanks for reading and hope you enjoy it. Feel free to check out my other posts and find me on X and GitHub!