Understanding how Vite deals with your node_modules
Jinjiang

Jinjiang @jinjiang

About: 0.1.x Engineer + ⚽️💻 Vue.js core team member & working at Bit.dev

Location:
Singapore
Joined:
May 26, 2020

Understanding how Vite deals with your node_modules

Publish Date: Apr 21
54 0

This is an article about Vite and its features which are related to the node_modules of your projects.

Background

As a Vite user so far, I guess there might be some questions in your head like:

  • Have you ever been confused about all the Vite configs for your project dependencies?
  • Have you ever wondered how Vite deals with it?
  • Have you ever tried to build a tool based on Vite and found it difficult to deal with the node_modules directory?
  • Have you ever seen a long error message in the browser or terminal that you have no idea what it means?
  • Does a CJS (CommonJS) dependency work in all the Vite cases?
  • Does a dependency in the normal SPA (Single Page Application) mode and the SSR (Server Side Rendering) mode work the same way?

In this article, we will explore how Vite deals with them in different modes and scenarios.

What is Vite?

Screenshot of Vite homepage

Vite is a modern frontend build tool that provides a fast and efficient development experience. It uses native ES modules in the browser for development, and bundles your code by Rollup for production. Today, Vite has dominated the frontend world as the most popular build tool. Even It has become the de facto standard for modern frontend development.

"Every frontend project has a messy node_modules folder"

One of the most magical things about Vite is that it can work with your node_modules folder pretty well.

We all know that in modern JavaScript ecosystem, the node_modules folder is a big trouble. It's large, messy, and difficult to manage. It contains all kinds of dependencies, including CJS (CommonJS), ESM (ECMAScript Modules), UMD (Universal Module Definition), and even some non-JavaScript files like CSS, images, etc. It's a nightmare for a build tool to deal with.

And one of its biggest challenges is to deal with the CJS code. We all know that Vite is a ESM-first build tool. The whole idea of Vite is to use native ES modules in the browser for development. However, in the real world, CJS is still everywhere, and it's not supported in the browser.

However, Vite can handle all the challenges above. Not only handling it precisely, but also performantly. In most of the time, you don't even have to think about it. It just works by default.

But I have to say. It's just most of the time. There are still some edge cases that you have to take care of manually. And this is what this article is discussing.

How did this article come about?

As part of my job, recently I'm working on integrating Vite (also Vitest) into a dev tool called Bit, which originally uses webpack in most of the cases. Basically, Bit is a component-driven development tool for various frontend frameworks and Node.js. In Bit, everything is a component and eventually consumed as an npm package. So technically, you would deal with all kinds of components as packages in your node_modules folder, whatever they are in CJS or ESM, need to be further transformed or not.

During the integration, I've met a lot of cases which need extra attentions. I've also had a lot of discussions with the Vite/Vitest team via GitHub issues/PRs/discussions and a little more Discord chats. I'm glad most of them have been eventually figured out. And I also learned a lot of interesting details.

I'm thinking, maybe I can write something down, to help more people who also have the same issues, or just want to know more about Vite. So here we are.

To be noticed #1 that all the demos have been snapshotted step-by-step on this GitHub repo Jinjiang/reproduction - branch: vite-deps-demo-2025. You can always check it out there. At this moment, the latest Vite version is v6.2.0.

To be noticed #2 that in the future Vite will integrate Rolldown as its new core. Technically their config would be different, more precisely saying, simpler.

Let's begin.

Basic usage

Setup

Let's start from the official guide to create a React project:

BTW, feel free to choose your favorite package manager. Here we use pnpm as an example.

$ pnpm create vite
│
◇  Project name:
│  vite-project
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Scaffolding project in /home/jinjiang/Developer/vite-project...
│
└  Done. Now run:

  cd vite-project
  pnpm install
  pnpm run dev
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #1 - basic setup

After the installation, we can see the node_modules folder is created with all the dependencies you need:

cd vite-project
pnpm install
Enter fullscreen mode Exit fullscreen mode

Let's run the project:

$ pnpm run dev

  VITE v6.2.1  ready in 180 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help
Enter fullscreen mode Exit fullscreen mode

Screenshot of the browser after the basic setup

Cool, now you can see the React project is running on http://localhost:5173/.

To better understand how it works, let's introduce 2 common inspection skills: node_modules/.vite folder and the "Network" panel in browser DevTools.

How to inspect 1: the node_modules/.vite/deps folder

First, you may have found there is a node_modules/.vite folder created with some files like this:

$ tree node_modules/.vite
node_modules/.vite
└── deps
    ├── _metadata.json
    ├── chunk-ENSPOGDT.js
    ├── chunk-ENSPOGDT.js.map
    ├── chunk-RO7GY43I.js
    ├── chunk-RO7GY43I.js.map
    ├── package.json
    ├── react-dom.js
    ├── react-dom.js.map
    ├── react-dom_client.js
    ├── react-dom_client.js.map
    ├── react.js
    ├── react.js.map
    ├── react_jsx-dev-runtime.js
    ├── react_jsx-dev-runtime.js.map
    ├── react_jsx-runtime.js
    └── react_jsx-runtime.js.map

1 directory, 16 files
Enter fullscreen mode Exit fullscreen mode

This folder is created by Vite to store some temporary files. And it's easy to guess that the deps subfolder is used to store something related to the dependencies. We will jump into the details later.

How to inspect 2: the "Network" panel in browser DevTools

Second, open the browser DevTools and check the "Network" panel. You will see all the requests to the Vite dev server. And some of them point to this node_modules/.vite/deps folder.

Screenshot of the DevTools after the basic setup

For example, the source file src/main.tsx:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
Enter fullscreen mode Exit fullscreen mode

is eventually requested into the browser roughly like (after some beautification):

// 'react/jsx-dev-runtime' transformed from jsx
import react_jsxDevRuntime from "/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=<hash>";
// originally 'react'
import react from "/node_modules/.vite/deps/react.js?v=<hash>";
// originally 'react-dom/client'
import reactDom_client from "/node_modules/.vite/deps/react-dom_client.js?v=<hash>";

import "/src/index.css";
import App from "/src/App.tsx";

const jsxDEV = react_jsxDevRuntime["jsxDEV"];
const StrictMode = react["StrictMode"];
const createRoot = reactDom_client["createRoot"];

createRoot(document.getElementById("root")).render(
  jsxDEV(StrictMode, {
    children: jsxDEV(App, {}, void 0, false, {...}, this)
  }, void 0, false, {...}, this)
);
Enter fullscreen mode Exit fullscreen mode

As you can see, all the package imports like import React from 'react' or import { createRoot } from 'react-dom/client' are resolved into a path to node_modules/.vite/deps/xxx.

A small recap

  • To inspect the dependencies, you can either check the code in node_modules/.vite/deps folder, or check the "Network" panel in browser DevTools. They usually just match each other.

This is the first Vite-magical thing we'd like to discover. More magically, whatever the source of a package import is in CJS or ESM, the eventual requested content will always be in ESM.

A simple case with CJS and ESM

Here, both react and react-dom are "bad" examples which are still CJS in 2025, but good examples to show how Vite deals with CJS package imports. To better understand how Vite deals with CJS and ESM package imports in a simpler way, let's create a new package:

mkdir node_modules/foo
touch node_modules/foo/package.json
touch node_modules/foo/foo-cjs.cjs
touch node_modules/foo/foo-esm.mjs
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/foo/package.json:
  {
    "name": "foo"
  }
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/foo/foo-cjs.cjs:
  exports.foo = 'foo-cjs'
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/foo/foo-esm.mjs:
  export const foo = 'foo-esm'
Enter fullscreen mode Exit fullscreen mode

This will create a package named foo with 2 files:

  • foo-cjs.cjs in CJS and
  • foo-esm.mjs in ESM.

Then add 2 imports into src/main.tsx:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

// 2 new imports
import { foo as fooCjs } from 'foo/foo-cjs.cjs'
import { foo as fooEsm } from 'foo/foo-esm.mjs'

import './index.css'
import App from './App.tsx'

console.log({ fooCjs, fooEsm });

...
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #2 - a case with both CJS and ESM

Restart the Vite dev server, then you will find these imports have also been resolved into a path like /node_modules/.vite/deps/foo-esm.js?v=<hash> but in different ways:

// first new import
import foo_fooCjs from "/node_modules/.vite/deps/foo_foo-cjs__cjs.js?v=<hash>";
const fooCjs = foo_fooCjs["foo"];

// second new import
import { foo as fooEsm } from "/node_modules/.vite/deps/foo_foo-esm__mjs.js?v=<hash>";
Enter fullscreen mode Exit fullscreen mode

At the same time, the eventual content of these 2 requested files are all in ESM:

  • node_modules/.vite/deps/foo_foo-cjs__cjs.js:
  import { __commonJS } from "/node_modules/.vite/deps/chunk-<hash1>.js?v=<hash2>";

  // node_modules/foo/foo-cjs.cjs
  var require_foo_cjs = __commonJS({
    "node_modules/foo/foo-cjs.cjs"(exports) {
      exports.foo = "foo-cjs";
    }
  });
  export default require_foo_cjs();
Enter fullscreen mode Exit fullscreen mode

it imports a builtin helper __commonJS to wrap the CJS source code into ESM.

  • node_modules/.vite/deps/foo_foo-esm__mjs.js:
  import "/node_modules/.vite/deps/chunk-<hash1>.js?v=<hash2>";

  // node_modules/foo/foo-esm.mjs
  var foo = "foo-esm";
  export {
    foo
  };
Enter fullscreen mode Exit fullscreen mode

it's almost the same as the original ESM source code.

And the __commonJS() helper looks like this:

  • node_modules/.vite/deps/chunk-<hash1>.js:
  var __getOwnPropNames = Object.getOwnPropertyNames;
  var __commonJS = (cb, mod) => function __require() {
      return mod || (0,
      cb[__getOwnPropNames(cb)[0]])((mod = {
          exports: {}
      }).exports, mod),
      mod.exports;
  }
  ;
  export { __commonJS };
Enter fullscreen mode Exit fullscreen mode

So overall:

  1. Vite can recognize whether a package import is in CJS or in ESM, not only per package but also per file, which quite makes sense.
  2. For ESM package import, it's straightforward, nothing special to do.
  3. For CJS package import (with exports.xxx in it), Vite will transform the package code (node_module/foo/foo-cjs.cjs) into ESM via default export. And it will be eventually imported as default in the importer file (src/main.tsx).

Please notice that, even though your CJS package import is in exports.xxx which is much closer to named exports in ESM, Vite will always transform it into default export in ESM. That is unituitive but makes sense because in CJS the exports can by complex and dynamic which are difficult to predict into static named imports, while putting all of them into a default export as a deal simplifies the transformation process. And later, we still have chance to distinguish between exports.foo = xxx and module.exports = xxx by a flag __esModule.

The case with module.exports = xxx in CJS

At last, you might also be curious about another kind of CJS package import module.exports = xxx.

Great question! Let's try it now:

touch node_modules/foo/foo-cjs-module.cjs
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/foo/foo-cjs-module.cjs:
  module.exports = 'foo-cjs-module'
Enter fullscreen mode Exit fullscreen mode
  • And then add the import into src/main.tsx:
  import fooCjsModule from 'foo/foo-cjs-module.cjs'
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #3 - module.exports in CJS

Restart the Vite server, now you will find the importer in the eventual requested content:

import fooImport from "/node_modules/.vite/deps/foo_foo-cjs-module__cjs.js?v=<hash>";
const fooCjsModule = fooImport.__esModule ? fooImport.default : fooImport;
Enter fullscreen mode Exit fullscreen mode

That means, Vite will try to access the default property of the CJS package import if there is a __esModule flag in it. Otherwise, it will use the import itself directly.

and the imported file looks like:

  • node_modules/.vite/deps/foo_foo-cjs-module__cjs.js:
  import { __commonJS } from "/node_modules/.vite/deps/chunk-<hash1>.js?v=<hash2>";

  // node_modules/foo/foo-cjs-module.cjs
  var require_foo_cjs_module = __commonJS({
    "node_modules/foo/foo-cjs-module.cjs"(exports, module) {
      module.exports = "foo-cjs-module";
    }
  });
  export default require_foo_cjs_module();
Enter fullscreen mode Exit fullscreen mode

Actually, even for the case you use exports.xxx in CJS package import, the resolved importing code would be in the same logic:

import fooCjsAll from 'foo/foo-cjs.cjs'
Enter fullscreen mode Exit fullscreen mode

will be resolved into:

import fooImport from "/node_modules/.vite/deps/foo_foo-cjs__cjs.js?v=<hash>";
const fooCjs = fooImport.__esModule ? fooImport.default : fooImport;
// `fooCjs.foo` will be 'foo-cjs'
Enter fullscreen mode Exit fullscreen mode

which also works as expected.

Snapshot of the demo project #4 - default import with exports.xxx in CJS

A small recap

  1. All the package imports will be resolved into imports to a path like /node_modules/.vite/deps/xxx.
  2. For ESM package import, it's more straightforward, almost nothing extra.
  3. For CJS package import, Vite will transform the package code into ESM with default export. And eventually, the consuming import code will be pointed to the default import as well.

The code inside node_modules/.vite/deps/ is generated by Vite which we call "dependency pre-bundling" or "dependency optimization". Next, let's dive into it and see how to customize it for your own needs.

Custom Dependency Pre-bundling

What is vite optimize and optimizeDeps config in Vite?

You may have noticed that, on Vite official docs, there is a vite optimize command. The job is actually part of what Vite dev server does. It can figure out all the needed dependencies in node_modules and then "pre-bundles" them into node_modules/.vite/deps/. Then all the package imports like react can be resolved to the new optimized imports like node_modules/.vite/deps/react.js instead of the original one.

The rough steps of this "optimize" process are:

  1. Traverse all the source code from the entry file of your project (usually it's the index.html) and find out all the imports to node_modules. and then draw a "optimization boundary" around them.
    • e.g. in the project above, the analyzation prcess would be:
      • index.html: imports src/main.tsx
      • src/main.tsx: imports react/jsx-dev-runtime (boundary), react (boundary), react-dom/client (boundary), ./App.tsx, ./index.css, foo/foo-cjs.cjs (boundary), foo/foo-esm.mjs (boundary), foo/foo-cjs-module.cjs (boundary)
      • ./App.tsx: imports react/jsx-dev-runtime (boundary), react (boundary), ./assets/react.svg, /vite.svg, ./App.css
      • ./index.css: no more imports
      • ./App.css: no more imports
      • ./assets/react.svg: no more imports
      • ./vite.svg: no more imports
    • Then the full "optimization boundary" would be:
      • react/jsx-dev-runtime (invisible from source code)
      • react (invisible from source code)
      • react-dom/client
      • foo/foo-cjs.cjs
      • foo/foo-esm.mjs
      • foo/foo-cjs-module.cjs
  2. Pre-bundle all the dependencies on the "optimization boundary" list into node_modules/.vite/deps/ folder. This step is done by esbuild.

After those, we are able to replace all the package imports into the optimized imports.

You usually don't have to do this because Vite dev server will do it automatically for you. Just in case you need to force it to be done or rebuilt, you can also run vite --force to force the rebuild.

Why we need dependency optimization?

In short:

  1. To support CJS package imports in the browser.
  2. To reduce the number of requests to the server for better performance.

What is optimizeDeps config in Vite?

Now, it's time to understand why we have a optimizeDeps config in Vite. This config is designed to give you more control over the "optimization boundary" settings. For example, you can exclude some packages from the boundary, or include more packages into the boundary.

This is very useful when Vite somehow is not able to do the analyzation correctly, or you want to skip some packages from the pre-bundling process for special reasons. For example, you may want to change the content of a certain package in a special case. Then, it's not recommended to pre-bundle it. Because once you have changed the content, the whole pre-bundle needs to be rebuilt, not to mention Vite may not rebuild it immediately during the dev server running. Even when you restart it, Vite sometimes can't detect the changes. So you have to force the rebuild manually to make it right.

Back to the demo project, first of all, let's disable the automatic analyzation by setting optimizeDeps.noDiscovery to true in vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  optimizeDeps: {
    noDiscovery: true,
  },
  plugins: [
    react(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #5 - optimizeDeps.noDiscovery

Then restart the Vite dev server by pnpm run dev --force. You will see your web app not working anymore with a browser runtime error:

Uncaught SyntaxError: The requested module '/node_modules/.pnpm/react-dom@19.0.0_react@19.0.0/node_modules/react-dom/client.js?v=4eaecffa' does not provide an export named 'createRoot'
Enter fullscreen mode Exit fullscreen mode

If you inspect the "Network" panel now, you will find the package imports like import { createRoot } from 'react-dom/client' in src/main.tsx are not resolved into node_modules/.vite/deps/xxx anymore. They will be pointed to the original package imports:

import { createRoot } from "/node_modules/.pnpm/react-dom@19.0.0_react@19.0.0/node_modules/react-dom/client.js?v=<hash>";
import { foo as fooCjs } from "/node_modules/foo/foo-cjs.cjs?import&v=<hash>";
import { foo as fooEsm } from "/node_modules/foo/foo-esm.mjs?v=<hash>";
import fooCjsAll from "/node_modules/foo/foo-cjs.cjs?import&v=<hash>";
import fooCjsModule from "/node_modules/foo/foo-cjs-module.cjs?import&v=<hash>";
Enter fullscreen mode Exit fullscreen mode

And You can also see the eventual content of react-dom/client.js is in CJS which doesn't work in browser. Now we know that Vite itself can't deal with CJS code directly without the "optimization".

To make the app works again, let's add all the package imports of the "optimization boundary" into the optimizeDeps.include config:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  optimizeDeps: {
    noDiscovery: true,
    include: [
      `react-dom/client`,
      'foo/foo-cjs.cjs',
      'foo/foo-esm.mjs',
      'foo/foo-cjs-module.cjs',
    ],
  },
  plugins: [
    react(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #6 - fix with optimizeDeps.include

Now, all the package imports are resolved back to node_modules/.vite/deps. And the web app is working again.

Among these optimizeDeps.include items, you actually can remove the ESM package imports like foo/foo-esm.mjs because the browser can work with them natively without the optimization:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  optimizeDeps: {
    noDiscovery: true,
    include: [
      `react-dom/client`,
      'foo/foo-cjs.cjs',
      // 'foo/foo-esm.mjs',
      'foo/foo-cjs-module.cjs',
    ],
  },
  plugins: [
    react(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #7 - remove ESM from optimizeDeps.include

Another way to achieve the same effect is to remove noDiscovery and include fields to turn all of them on but exclude the ESM package imports from the "optimization boundary" by setting optimizeDeps.exclude:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  optimizeDeps: {
    exclude: [
      'foo/foo-esm.mjs',
    ],
  },
  plugins: [
    react(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #8 - equivalent optimizeDeps.exclude

This also means, in node_modules, only ESM content can be technically modified without the pre-bundle rebuild. Otherwise, all the code changes in node_modules need a forced pre-bundle rebuild to make it works. However, to support the advanced features like HMR (Hot Module Replacement), you need something else. This is framework-specific and won't be covered in this article.

A small recap:

  1. By default, Vite will automatically analyze the project and draw a "optimization boundary" around all the package imports. The "optimization boundary" is used to pre-bundle the dependencies into ESM and put them into node_modules/.vite/deps.
  2. Option optimizeDeps.noDiscovery can be set to disable the default deps analyzation.
  3. Option optimizeDeps.include and optimizeDeps.exclude are used to opt-in/opt-out certain package imports to/from the "optimization boundary".
  4. Command vite optimize and vite --force can be used to do the optimization manually.

And it's easy to get those tips below:

  • Never exclude CJS package imports.
  • If you want to include all the dependencies by default and exclude some exceptions, just use optimizeDeps.exclude.
  • If you want to manually list the "boundary" from zero, use optimizeDeps.noDiscovery + optimizeDeps.include instead.

Summary for normal (SPA) mode

Advanced case: deep dependencies configuration

Now, let's think about an advanced case: what if I'd like to exclude a package import like foo/foo-esm.mjs but include (optimize) one of its package imports to a deep dependency (in CJS)?

Let's create a deep dependency foo-dep-a with a CJS file foo-dep-a-cjs.cjs:

mkdir node_modules/foo/node_modules
mkdir node_modules/foo/node_modules/foo-dep-a
touch node_modules/foo/node_modules/foo-dep-a/package.json
touch node_modules/foo/node_modules/foo-dep-a/foo-dep-a-cjs.cjs
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/foo/node_modules/foo-dep-a/package.json:
  {
    "name": "foo-dep-a"
  }
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/foo/node_modules/foo-dep-a/foo-dep-a-cjs.cjs:
  exports.fooDepA = 'foo-dep-a-cjs'
Enter fullscreen mode Exit fullscreen mode
  • And then add the import into node_modules/foo/foo-esm.mjs:
  import { fooDepA } from 'foo-dep-a/foo-dep-a-cjs.cjs'
  export const foo = fooDepA
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #9 - a case of deep deps

Restart the server, you will see the browser runtime error when you opt-out foo/foo-esm.mjs since the new CJS file isn't optimized.

To fix it, we can use this syntax to include the deep dependency:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  optimizeDeps: {
    include: [
      'foo > foo-dep-a/foo-dep-a-cjs.cjs',
    ],
    exclude: [
      'foo/foo-esm.mjs',
    ],
  },
  plugins: [react()],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #10 - fix with deep optimizeDeps.include

For the optimizeDeps config, besides noDiscovery, include, and exclude, there are also some other options like entries, esbuildOptions, holdUntilCrawlEnd, needsInterop, etc. You can check the official docs for more details.

Also, to force the pre-bundle rebuild, there is also a config optimizeDeps.force which does the same thing as vite --force. It's very useful for debugging purposes. We will turn it on by default in the following sections.

I guess so far everything is still quite clear and straightforward. And nothing is uncovered from the official docs yet, right? Next, let's dive into the SSR (Server Side Rendering) world. I bet you will find something really interesting and surprising.

Basic SSR usage

The SSR mode will let your project run more logics on the server side, which is quite different from the default (SPA) mode. In short, comparing to SPA, SSR will additionally:

  • render the app into a html string on the server, and
  • run most of the code in the server environment like Node.js rather than only in the browser.

This brings more technical challenges, especially when it comes to the node_modules in a mixed CJS and ESM world. Let's see how Vite deals with it.

Setup

First thing first, let's turn this project into a SSR one. For demonstration, we will do it in a minimal way:

  1. Create 2 entries: src/client.tsx and src/server.tsx instead of the original src/main.tsx.
  2. Update index.html to include src/client.tsx instead of src/main.tsx. at the same time, add a placeholder inside the <div id="root"></div> as <div id="root"><!--ssr-outlet--></div> for the server-rendered content replacement.
  3. Add a run.mjs file to run the whole service.
  4. Add a dev:ssr npm scripts in package.json with node ./run.mjs.
  5. And don't forget to install express and compression which are used in run.mjs.

Some particular file changes are:

  • src/client.tsx:
  import { createRoot, hydrateRoot } from 'react-dom/client';
  import './index.css'
  import App from './App';

  const root = document.getElementById('root');

  if (import.meta.env.SSR) {
    hydrateRoot(root!, (<App />));
  }
  else {
    createRoot(root!).render(<App />);
  }
Enter fullscreen mode Exit fullscreen mode
  • src/server.tsx:
  import { renderToString } from "react-dom/server";
  import App from './App';

  export const render = async () =>
    renderToString(<App />);
Enter fullscreen mode Exit fullscreen mode
  • run.mjs:
  import { readFileSync } from "node:fs";
  import express from "express";
  import compression from 'compression'
  import { createServer } from "vite";

  const PORT = 5173;

  // create Vite dev server in middleware mode
  const devServer = await createServer({
    server: { middlewareMode: true },
    appType: "custom",
  });

  // create the main server with an express app
  const app = express();
  app.use(compression());
  app.use(devServer.middlewares);

  // handle all the requests with the Vite dev server
  app.use("/", async (req, res, next) => {
    const url = req.originalUrl;

    try {
      // 1. get the index.html template
      // 2. transform the template with some necessary setup
      // 3. render the app into a string
      // 4. replace the final html with the rendered app string
      const template = readFileSync('./index.html', "utf-8");
      const tranformedTemplate = await devServer.transformIndexHtml(url, template);
      const { render } = await devServer.ssrLoadModule('./src/server.tsx');
      const appHtml = await render();
      const html = tranformedTemplate.replace(`<!--ssr-outlet-->`, appHtml);
      res.statusCode = 200;
      res.setHeader("Content-Type", "text/html");
      res.end(html);
    } catch (error) {
      next(error);
    }
  });

  // listen to the port
  app.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
  });
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #11 - SSR setup

Now you can run the project with either pnpm run dev or pnpm run dev:ssr. It will run the web app on http://localhost:5173 in normal (SPA) or SSR mode. And you may already guess out in SSR mode, Vite deals with node_modules far more differently from SPA mode.

What is ssr.external and ssr.noExternal config in Vite?

Imagine there could be all kinds of content in node_modules which are:

  1. JavaScript files 🆚 other files like CSS files, TypeScript file, JSX files, etc.
  2. in ESM 🆚 in CJS, if it's a JavaScript file.
  3. pure JavaScript code which can be run in the server environment like Node.js (whatever it's in ESM or CJS) 🆚 not (e.g. having CSS imports or assets imports that need further transformation before the running in server).

It's easy to confirm that technically all the pure JavaScript code can be run in the server environment without transformation. But the non-pure JavaScript code have to be transformed beforehand.

With this background, the term "external" is introduced as a special concept in Vite SSR. It has a similar role as optimizeDeps for SPA to draw a "transformation boundary" for the server environment to decide whether the code needs to be transformed beforehand. And of course, their criteria are slightly different:

  • In SPA mode, the main issue of the package imports is the CJS support. So we have to optimize all the CJS code into ESM beforehand.
  • In SSR mode, the main issue of the package imports is non-pure JavaScript code. So we have to transform all of them beforehand.

A simple case

As the first example, let's see what if we have a package import which is in ESM but not pure JavaScript. Let's create a package named bar with an ESM file bar-esm.mjs and a CSS file style.css:

mkdir node_modules/bar
touch node_modules/bar/package.json
touch node_modules/bar/bar-esm.mjs
touch node_modules/bar/style.css
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/bar/package.json:
  {
    "name": "bar"
  }
Enter fullscreen mode Exit fullscreen mode
  • node_modules/bar/bar-esm.mjs:
  import './style.css'

  export const bar = 'bar'
Enter fullscreen mode Exit fullscreen mode
  • node_modules/bar/style.css:
  .bar {
    background-color: red;
  }
Enter fullscreen mode Exit fullscreen mode

Then we update src/App.tsx to consume Bar only:

  • src/App.tsx:
  import { bar } from 'bar/bar-esm.mjs'

  export default function App() {
    return (<h1 className='bar'>{bar}</h1>)
  }
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #12 - a case for SSR externals

Now, if you run the project in SPA mode via pnpm run dev, it works. However, in SSR mode via pnpm run dev:ssr, you will see the error both in browser runtime and server environment (terminal):

00:00:00 AM [vite] (ssr) Error when evaluating SSR module ./src/server.tsx: Unknown file extension ".css" for /xxx/node_modules/bar/style.css
Enter fullscreen mode Exit fullscreen mode

That's because the CSS import wasn't transformed into pure JavaScript beforehand. To do that, we need to add the package import into the ssr.noExternal config in vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  ssr: {
    // ensure all the imports of 'bar' being transformed
    // before running in the server environment
    noExternal: ['bar'],
  },
  plugins: [
    react(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #13 - fix with ssr.noExternal

With ths config, all the package imports of bar will be transformed by Vite before running in the server environment. So it works.

How to inspect 3: the module evaluation process in SSR mode

To better understand how it works in SSR mode, honestly, I didn't find an easy way to inspect what happens, especially when it goes to an error. You know what, let's dive into the Vite source in a hard way. Don't be scared, it's not that difficult.

You have 2 options to go:

  1. git clone the Vite source code, install all the dependencies, modify something, rebuild it, and then link it to your project.
  2. a little dirty but quick one, just jump into the node_modules/vite folder and modify the dist code directly.

Whatever you choose, find a class named ESModulesEvaluator:

  • in source code it's in packages/vite/src/module-runner/esmEvaluator.ts
  • in node_modules/vite it's in node_modules/vite/dist/node/module-runner.js

In short, this class defines 2 methods runExternalModule() and runInlinedModule() to run the code as "external" or "inlined" ("noExternal") in SSR mode.

The simplified version is below:

// simplified `ESModulesEvaluator`
// - in source code: `packages/vite/src/module-runner/esmEvaluator.ts`
// - in generated code: `node_modules/vite/dist/node/module-runner.js`
export class ESModulesEvaluator implements ModuleEvaluator {
  async runInlinedModule(code: string): Promise<any> {
    const initModule = new AsyncFunction(`"use strict";${code}`)
    await initModule(...)
  }
  runExternalModule(filepath: string): Promise<any> {
    return import(filepath)
  }
}
Enter fullscreen mode Exit fullscreen mode

It's easy to imagine that:

  • For external packages, it will go to the runExternalModule() method which is the native import statement in server environment like Node.js.
  • For noExternal packages, it will go to the runInlinedModule() method which is a custom AsyncFunction to run the code, which has an opportunity to be transformed beforehand.

You can add some logs to print the code and filepath in these 2 methods to see both the transformed/resolved content and their execution order. Now re-run the server, I believe you will find something really interesting being printed.

Execution differences between "external" and "inlined" packages

Let's do it without the noExternal: ['bar'] config first:

// 1st request (inlined)
const __0__ = await __vite_ssr_import__("react/jsx-dev-runtime", {"importedNames":["jsxDEV"]});
const __1__ = await __vite_ssr_import__("react-dom/server", {"importedNames":["renderToString"]});
const __2__ = await __vite_ssr_import__("/src/App.tsx", {"importedNames":["default"]});

const render = async () => __1__.renderToString(
  __0__.jsxDEV(__2__.default, ..., this)
);

Object.defineProperty(
  __vite_ssr_exports__,
  "render",
  { enumerable: true, configurable: true, get() { return render } }
);

// 2nd request (external)
file:///xxx/node_modules/.pnpm/react@19.0.0/node_modules/react/jsx-dev-runtime.js

// 3rd request (external)
file:///xxx/node_modules/.pnpm/react-dom@19.0.0_react@19.0.0/node_modules/react-dom/server.node.js

// 4th request (inlined)
const __0__ = await __vite_ssr_import__("react/jsx-dev-runtime", {"importedNames":["jsxDEV"]});
const __1__ = await __vite_ssr_import__("bar/bar-esm.mjs", {"importedNames":["bar"]});

function App() {
  return __0__.jsxDEV(..., this);
}

Object.defineProperty(
  __vite_ssr_exports__,
  "default",
  { enumerable: true, configurable: true, value: App }
);

// 5th request (external)
file:///xxx/node_modules/bar/bar-esm.mjs

// and then the runtime error
// 00:00:00 AM [vite] (ssr) Error when evaluating SSR module ./src/server.tsx: Unknown file extension ".css" for /xxx/node_modules/bar/style.css
Enter fullscreen mode Exit fullscreen mode

According to the printed logs, now we know:

  1. The runtime error was caused by the 5th request, which import(./node_modules/bar/bar-esm.mjs) natively, while bar-esm.mjs is non-pure JavaScript.
  2. All the imports in inlined resources are transformed into a await __vite_ssr_import__() function call, which is an async custom helper function to load the target asynchronously. Actually, it's easy to guess this helper function calls native await import() inside. So basically, it turns the static imports into dynamic ones.
  3. Also, the helper function await __vite_ssr_import__() has a second parameter which accepts an object with importedNames property. This is used to describe the target name(s) to be imported.

Great! Now, let's add the noExternal: ['bar'] config back and run the server again. You will find from the 4th request, the logs are different:

...

// 4th request (external)
const __0__ = await __vite_ssr_import__("react/jsx-dev-runtime", {"importedNames":["jsxDEV"]});
const __1__ = await __vite_ssr_import__("/node_modules/bar/bar-esm.mjs", {"importedNames":["bar"]});

function App() {
  return __0__.jsxDEV(..., this);
}

Object.defineProperty(
  __vite_ssr_exports__,
  "default",
  { enumerable: true, configurable: true, value: App }
);

// 5th request (inlined)
const __0__ = await __vite_ssr_import__("/node_modules/bar/style.css");

const bar = 'bar'

Object.defineProperty(
  __vite_ssr_exports__,
  "bar",
  { enumerable: true, configurable: true, get() { return bar } }
);

// 6th request (inlined)
// (empty, actually it's from ./node_modules/bar/style.css)

// the end
Enter fullscreen mode Exit fullscreen mode

If you look carefully, you will find:

  1. The 5th request becomes inlined which further makes the CSS import as the 6th request as an inlined resource. But more importantly,
  2. in the 4th request, the original __vite_ssr_import__("bar/bar-esm.mjs") is now replaced with __vite_ssr_import__("/node_modules/bar/bar-esm.mjs").

From this difference, we can roughly guess that: Vite will treat

  • the package imports like bar/bar-esm.mjs as external, while
  • the relative path import like ./node_modules/bar/bar-esm.mjs as inlined.

Don't use named imports in an inlined file for an external module.exports CJS resource

There is actually a following special case that needs your attention.

Let's create another deep dependency with pure CJS code:

mkdir node_modules/bar/node_modules
mkdir node_modules/bar/node_modules/bar-sub
touch node_modules/bar/node_modules/bar-sub/package.json
touch node_modules/bar/node_modules/bar-sub/bar-sub-cjs.cjs
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/bar/node_modules/bar-sub/package.json:
  {
    "name": "bar-sub"
  }
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/bar/node_modules/bar-sub/bar-sub-cjs.cjs:
  exports.barSub = 'bar-sub-cjs'
Enter fullscreen mode Exit fullscreen mode
  • And then update node_modules/bar/bar-esm.mjs to import bar-sub:
  import { barSub } from 'bar-sub/bar-sub-cjs.cjs'
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #14 - SSR external CJS with exports.xxx

It works in both SPA and SSR mode. However, if we change the CJS code into module.exports style:

  • In node_modules/bar/node_modules/bar-sub/bar-sub-cjs.cjs:
  module.exports = {
    barSub: 'bar-sub-cjs',
  }
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #15 - SSR external CJS with module.exports

After this change, the SPA mode still works, but the SSR mode will throw a runtime error:

SyntaxError: [vite] Named export 'barSub' not found. The requested module 'bar-sub/bar-sub-cjs.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from 'bar-sub/bar-sub-cjs.cjs';
const {barSub} = pkg;
Enter fullscreen mode Exit fullscreen mode

You might have seen the similar error when you use the popular JS lib called "lodash". When you import lodash via a named import like import { merge } from 'lodash, it will throw the same runtime error message in SSR mode.

After knowing package imports are treated as await import() in Vite SSR mode. It's easy to understand what happened: the native import() can't get the named exports from a module.exports CJS resource.

Let's do a simple quick test. Open a Node.js REPL and run the following code:

await import('./node_modules/bar/node_modules/bar-sub/bar-sub-cjs.cjs')
// [Module: null prototype] { default: { barSub: 'bar-sub-cjs' } }
Enter fullscreen mode Exit fullscreen mode

As you see the imported result is all in the default field. So a statement like const { barSub } = await import() won't get what you want. But if you change the CJS code back to exports.barSub = 'bar-sub-cjs', it will work as expected:

await import('./node_modules/bar/node_modules/bar-sub/bar-sub-cjs.cjs')
// [Module: null prototype] {
//   barSub: 'bar-sub-cjs',
//   default: { barSub: 'bar-sub-cjs' }
// }
Enter fullscreen mode Exit fullscreen mode

So, as the error message suggested, in this case you have to use the default import instead of the named import:

import pkg from 'bar-sub/bar-sub-cjs.cjs'
const { barSub } = pkg;
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #16 - fix with default import + destructure

The package 'lodash' is exactly the same case. It uses module.exports to export the whole object, which is not compatible with the named import syntax. So you have to use the default import instead:

import pkg from 'lodash'
const { merge } = pkg
Enter fullscreen mode Exit fullscreen mode

Now, we already know how Vite deals with JavaScript code as inlined or external resource in server environment, and how to inspect the whole process. Next, we will explore all the other ways to configure them in Vite.

Other relevant options

Beside ssr.noExternal, there is another option ssr.external to ensure certain packages to be executed directly without transformation.

For either of the options, giving a true value means all the packages will be transformed or not. You can also specify a package list to another option to toggle them off or on.

For example:

{
  ssr: {
    // all the packages will be executed directly, except...
    external: true,
    // only 'foo' and 'bar' will be transformed
    noExternal: ['foo', 'bar'],
  },
}
Enter fullscreen mode Exit fullscreen mode

or:

{
  ssr: {
    // all the packages will be transformed, except...
    noExternal: true,
    // only 'react' and 'react-dom' will be executed directly
    external: ['react', 'react-dom'],
  },
}
Enter fullscreen mode Exit fullscreen mode

To choose which way to configure, I think it depends on whether your project is more likely to have more packages to be transformed or not. However, by default, ssr.external is true and ssr.noExternal is []. For ssr.noExternal, you can even use regular expressions to match the package names like:

{
  ssr: {
    // `external` is `true` by default
    noExternal: [/^foo/, 'bar'],
  },
}
Enter fullscreen mode Exit fullscreen mode

Advanced case: deep dependencies configuration

Now for a Vite project with a simple dependency, I guess that's enough to go. However, what about the deep dependencies? For example, what if the package bar has a sub-dependency named bar-dep-a? Will it be transformed or not? May I specify bar-dep-a in ssr.noExternal or ssr.external?

Well, that's a little bit tricky:

  1. if a package has been specified as ssr.external, all its sub-dependencies will be treated as external as well. there is no way to interfere with it anymore.
  2. if a package has been specified as ssr.noExternal, all its sub-dependencies will still be treated according to the ssr.external and ssr.noExternal settings.

For example, if the dependency tree looks like:

bar
└── bar-dep-a
    └── bar-dep-b
Enter fullscreen mode Exit fullscreen mode

Then:

  1. config:

    {
      ssr: {
        external: true,
        noExternal: ['bar-dep-a'],
      },
    }
    

    will make all of them external since the top-level bar is external and the setting for its sub-dependency bar-dep-a won't take effect.

  2. config:

    {
      ssr: {
        external: true,
        noExternal: ['bar'],
      },
    }
    

    will make bar inline, but bar-dep-a and bar-dep-b still external.

  3. config:

    {
      ssr: {
        external: true,
        noExternal: ['bar', 'bar-dep-a', 'bar-dep-b'],
      },
    }
    

    or:

    {
      ssr: {
        external: true,
        noExternal: [/^bar/],
      },
    }
    

    or:

    {
      ssr: {
        noExternal: true,
        external: [],
      },
    }
    

    will make all of them inline.

non-JS files in node_modules

During writing this article, I've tried multiple different combinations of the files and configs. Although the official docs say that Vite will "externalized all dependencies by default", I still found some cases that Vite will transform and inline them, which are the non-JS files like TSX. For example, if we create a TSX file with the same content as bar-esm.mjs:

touch node_modules/bar/bar-tsx.tsx
Enter fullscreen mode Exit fullscreen mode
  • node_modules/bar/bar-tsx.tsx:
  import './style.css'

  export const bar = 'bar'
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #17 - non-JS file

Then update src/App.tsx to import bar/bar-tsx.tsx instead, the server will still work as expected without the noExternal: ['bar'] config.

A small recap:

  1. Vite will treat all the JavaScript files in node_modules as external by default, whatever it's in CJS or ESM.
  2. Vite will treat all the non-JS files (e.g. TS/TSX files) in node_modules as inlined and transform them by default before execution.
  3. You can configure ssr.noExternal to transform certain packages and execute them as inlined resources. This is useful when the resource is not pure JavaScript code. e.g. with CSS imports or assets imports.
  4. If you set a package as "external", all its sub-dependencies will be treated as external as well. You can't change it anymore.
  5. If you set a package as "noExternal", all its sub-dependencies will be also treated according to the ssr.external and ssr.noExternal settings.
  6. You can either set ssr.external or ssr.noExternal to true and then specify certain packages in the other one. For ssr.noExternal, you can also use a regular expression to match the package names.
  7. If you want to inline a certain package and all its deep dependencies, you have to set ssr.noExternal to true or explicitly list all of them in ssr.noExternal, and luckly we have regular expressions to help.

Till now, there is still a special case not discussed yet: what about a non-pure CJS package import in SSR mode? For example a CJS file which includes a require('./style.css') in it. Obviously, Vite can't load it directly, nor transform it neither. How should we deal with it?

You may wonder if we could somehow "optimize" it like Vite does in SPA mode. The answer is yes. But it's not enabled by default. Next, let's dive into this topic.

What is ssr.optimizeDeps config in Vite?

In short, ssr.optimizeDeps is like the second "optimization boundary" for SSR mode. So with this "optimization boundary", we can optimize CJS code into ESM. And then with the first "transformation boundary", we can transform the non-pure JavaScript code into pure JavaScript code.

A simple case

Let's make a scenario to demonstrate the problem. Just create a package named baz with a CJS file baz-cjs.cjs and a CSS file style.css:

mkdir node_modules/baz
touch node_modules/baz/package.json
touch node_modules/baz/baz-cjs.cjs
touch node_modules/baz/style.css
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/baz/package.json:
  {
    "name": "baz"
  }
Enter fullscreen mode Exit fullscreen mode
  • node_modules/baz/baz-cjs.cjs:
  require('./style.css')

  exports.baz = 'baz'
Enter fullscreen mode Exit fullscreen mode
  • node_modules/baz/style.css:
  .baz {
    color: white;
    background-color: green;
  }
Enter fullscreen mode Exit fullscreen mode

Now, update src/App.tsx to consume Baz only:

  • src/App.tsx:
  import { baz } from 'baz/baz-cjs.cjs'

  export default function App() {
    return (<h1 className='baz'>{baz}</h1>)
  }
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #18 - a case for non-pure CJS

If you run the project in SPA mode via pnpm run dev, it works as expected. However, if you run it in SSR mode via pnpm run dev:ssr, you will see the runtime error:

/<project-root>/node_modules/baz/style.css:1
.baz {
^

SyntaxError: Unexpected token '.'
    at wrapSafe (node:internal/modules/cjs/loader:1281:20)
    ...
Enter fullscreen mode Exit fullscreen mode

Being different from the client side, the ssr.optimizeDeps config for the server-side is not enabled by default.

To solve it, as we mentioned before, we need to manually add it into vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  ssr: {
    optimizeDeps: {
      include: [
        'baz/baz-cjs.cjs'
      ],
    },
    noExternal: [
      'baz'
    ]
  },
  plugins: [react()],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #19 - fix with ssr.noExternal + ssr.optimizeDeps

It includes 2 separate options:

  • ssr.optimizeDeps.include: to specify the package imports to be pre-bundled (baz/baz-cjs.cjs).
  • ssr.noExternal: to specify the packages not be executed directly (baz). So the pre-bundled baz/baz-cjs.cjs can be consumed instead.

Now, restart the server, it works as expected.

How to inspect 4: the node_modules/.vite/deps_ssr folder

To see what happened behind, now you can find an additional ./node_modules/.vite/deps_ssr folder generated besides ./node_modules/.vite/deps and quite similar to it. It's for SSR mode as we mentioned above. e.g. the package import baz/baz-cjs.cjs has a corresponding file there ./node_modules/.vite/deps_ssr/baz_baz-cjs__cjs.js. This is the actual resource that will be consumed in the server environment.

$ tree node_modules/.vite/
node_modules/.vite/
├── deps
│   ├── ...
│   └── ...
└── deps_ssr
    ├── _metadata.json
    ├── baz_baz-cjs__cjs.js
    ├── baz_baz-cjs__cjs.js.map
    └── package.json

2 directories, 26 files
Enter fullscreen mode Exit fullscreen mode

And of course, you can also use ssr.optimizeDeps.exclude as you like according to your situation.

If you also inspect the module evaluation process, you will see:

  • without noExternal: ['baz'], the execution code is like:
  ...
  const __1__ = await __vite_ssr_import__("baz/baz-cjs.cjs", {"importedNames":["baz"]});
  ...
Enter fullscreen mode Exit fullscreen mode

which is still a package import and will be imported natively which causes the runtime error.

  • with noExternal: ['baz'], the execution code is like:
  ...
  const __1__ = await __vite_ssr_import__("/node_modules/.vite/deps_ssr/baz_baz-cjs__cjs.js?v=<hash>", {"importedNames":["default"]});
  ...
Enter fullscreen mode Exit fullscreen mode

which is an optimized import in node_modules/.vite/deps_ssr. So it works as expected.

I think this is the last piece of the puzzle for all kinds of dependency consumptions in Vite.

A small recap

  1. For pure ESM code, you can go anywhere.
  2. For pure CJS code, you can go to ssr.external. Vite will load them directly.
  3. For ESM code with CSS imports or assets imports, you can go to ssr.noExternal. Vite will transform them before execution.
  4. For CJS code with CSS imports or assets imports, you can go to both ssr.noExternal and ssr.optimizeDeps. Vite will pre-bundle them first into ESM and then transform them before execution. And of course this approach is also available for pure CJS code.

Summary for SSR mode

Also remember:

  • optimizeDeps is enabled by default in client-side. You can disable it by setting optimizeDeps.noDiscovery.
  • optimizeDeps is disabled by default in server-side.
  • external is by enabled default in SSR mode. You can disable it by setting ssr.noExternal as true.

So far, that's all about how Vite deals with all kinds of dependencies in your projects. Next, let's talk about some other Vite config options those can possibly confuse you by affecting the dependency consumptions.

What if I add resolve.alias in Vite config?

In Vite, resolve.alias is used to create aliases for particular imports. It follows rollup's alias plugin config. Usually, you need to specify a find string or regexp to match the import path, and then a replacement string. As an advanced usage, you can also specify a customResolver function to resolve the import path further.

Here, we will focus on the replacement of package imports or related to certain resources in node_modules. For example, you might want to make a certain package in "singleton" mode, that means all the package imports with the same name should go to the same file. Let's try to do it with resolve.alias.

A simple case

First, let's create another version of package foo as a dependency of bar:

mkdir node_modules/bar/node_modules/foo
touch node_modules/bar/node_modules/foo/package.json
touch node_modules/bar/node_modules/foo/foo-cjs.cjs
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/bar/node_modules/foo/package.json:
  {
    "name": "foo",
    "version": "2.0.0"
  }
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/bar/node_modules/foo/foo-cjs.cjs:
  exports.foo = 'foo-cjs-v2'
Enter fullscreen mode Exit fullscreen mode
  • next, update node_modules/bar/bar-esm.mjs to consume foo:
  import './style.css'
  import { foo } from 'foo/foo-cjs.cjs'

  export const bar = foo
Enter fullscreen mode Exit fullscreen mode
  • revert the src/App.tsx to consume Bar again:
  import { foo } from 'foo/foo-cjs.cjs'
  import { bar } from 'bar/bar-esm.mjs'

  console.log({ foo, bar })

  export default function App() {
    return (<h1 className='bar'>{foo === bar ? 'Yes' : 'No' }</h1>)
  }
Enter fullscreen mode Exit fullscreen mode
  • revert the vite.config.ts to mark bar as inlined:
  import { defineConfig } from 'vite'
  import react from '@vitejs/plugin-react'

  // https://vite.dev/config/
  export default defineConfig({
    ssr: {
      noExternal: ['bar'],
    },
    plugins: [
      react(),
    ],
  })
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #20 - a case for resolve.alias

Now, restart the Vite dev server, you will see "No" in the browser, in both SPA mode and SSR mode. Because the 2 imports are from different versions of foo.

Let's add an alias in vite.config.ts to make them the same:

import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

const __dirname = dirname(fileURLToPath(import.meta.url));

// https://vite.dev/config/
export default defineConfig({
  resolve: {
    alias: [
      {
        find: 'foo',
        replacement: `${__dirname}/node_modules/foo`,
      },
    ],
  },
  ssr: {
    noExternal: ['bar'],
  },
  plugins: [
    react(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

It's also recommended to install @types/node as a dev dependency to avoid type errors in the code above.

Snapshot of the demo project #21 - fix SPA with resolve.alias

Now you can see the rendering result is "Yes" in SPA mode via pnpm run dev. That means the 2 imports are from the same version of foo. However, in SSR mode via pnpm run dev:ssr, you will see the runtime error:

00:00:00 AM [vite] (ssr) Error when evaluating SSR module ./src/server.tsx: exports is not defined.
       at eval (/xxx/node_modules/foo/foo-cjs.cjs:3:14)
       ...
Enter fullscreen mode Exit fullscreen mode

If you inspect the module evaluation process, you will see:

  • before the alias:
  // inlined
  const __1__ = await __vite_ssr_import__("foo/foo-cjs.cjs", {"importedNames":["foo"]});
  const __2__ = await __vite_ssr_import__("/node_modules/bar/bar-esm.mjs", {"importedNames":["bar"]});
Enter fullscreen mode Exit fullscreen mode
  • after the alias:
  // inlined
  const __1__ = await __vite_ssr_import__("/node_modules/foo/foo-cjs.cjs", {"importedNames":["foo"]});
  const __2__ = await __vite_ssr_import__("/node_modules/bar/bar-esm.mjs", {"importedNames":["bar"]});
Enter fullscreen mode Exit fullscreen mode

The package import of foo/foo-cjs.cjs became a relative path import after the alias. So it will never be imported as an external resource. And it further caused the runtime error because it was written in CJS.

I've tried multiple different combinations of ssr.external and resolve.alias settings to fix it but they all failed. The lesson I learned from this case is that:

A small recap

  1. The resolve.alias config will affect the package imports in node_modules and make them into relative path imports. This further makes them always inlined in SSR mode.

So, it's easy to know if you have a CJS package needs to be ensured as singleton, resolve.alias doesn't help in SSR mode. You need something else.

What if I add resolve.dedupe in Vite config?

According to the official docs, besides resolve.alias, Vite also provides another option resolve.dedupe to deduplicate the package imports. Does it work here? Let's give it a try.

Fix the previous case

In vite.config.ts, remove the resolve.alias config and add the resolve.dedupe:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  resolve: {
    dedupe: ['foo'],
  },
  ssr: {
    noExternal: ['bar'],
  },
  plugins: [
    react(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #22 - fix SSR with resolve.dedupe

Then try pnpm run dev and pnpm run dev:ssr. They both work now.

How does it work?

If you inspect the module evaluation process, you will find the foo/foo-cjs.cjs import is still the package import:

// inlined
const __1__ = await __vite_ssr_import__("foo/foo-cjs.cjs", {"importedNames":["foo"]});
Enter fullscreen mode Exit fullscreen mode

which ensures the CJS code to be executed as an external resource. If you wonder how resolve.dedupe works, you can check the source code in packages/vite/src/node/plugins/resolve.ts:

export function tryNodeResolve(...) {
  ...
  if (dedupe.includes(pkgId)) {
    basedir = root
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

The way it works is simple: for packages on the dedupe list, Vite always resolves them from the project root instead of the importer's location. So the package import foo/foo-cjs.cjs all over the project will be resolved to the same file <project-root>/node_modules/foo/foo-cjs.cjs.

Then the last question: does resolve.dedupe even work with external resources?

Another case with external resources

According to its implementation, if a package import happens in an external resource, there is no way to deduplicate it. For example, let's create a package named qux with another version of foo as a dependency, at the same time, add a pure JavaScript file in qux which imports foo/foo-cjs.cjs:

mkdir node_modules/qux
touch node_modules/qux/package.json
touch node_modules/qux/qux-cjs.cjs
mkdir node_modules/qux/node_modules
mkdir node_modules/qux/node_modules/foo
touch node_modules/qux/node_modules/foo/package.json
touch node_modules/qux/node_modules/foo/foo-cjs.cjs
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/qux/package.json:
  {
    "name": "qux"
  }
Enter fullscreen mode Exit fullscreen mode
  • node_modules/qux/qux-cjs.cjs:
  const { foo } = require('foo/foo-cjs.cjs')

  exports.qux = foo
Enter fullscreen mode Exit fullscreen mode
  • In node_modules/qux/node_modules/foo/package.json:
  {
    "name": "foo",
    "version": "3.0.0"
  }
Enter fullscreen mode Exit fullscreen mode
  • node_modules/qux/node_modules/foo/foo-cjs.cjs:
  exports.foo = 'foo-cjs-v3'
Enter fullscreen mode Exit fullscreen mode

Now, update src/App.tsx to consume Qux:

  • src/App.tsx:
  import { foo } from 'foo/foo-cjs.cjs'
  import { qux } from 'qux/qux-cjs.cjs'

  console.log({ foo, qux })

  export default function App() {
    return (<h1>{foo === qux ? 'Yes' : 'No' }</h1>)
  }
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #23 - a case for resolve.dedupe with external resources

In this case, you will find the client-side rendering result is "Yes", while the server-side rendering result is "No" (see the requested HTML content from server).

Screenshot of the SSR result with a

To make the server-side works, we have to make the qux package as ssr.noExternal, however, qux/qux-cjs.cjs is in CJS which can't be transformed properly. So we additionally need to add ssr.optimizeDeps.include to pre-bundle it:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  resolve: {
    dedupe: ['foo'],
  },
  ssr: {
    optimizeDeps: {
      include: ['qux/qux-cjs.cjs'],
    },
    noExternal: ['qux'],
  },
  plugins: [react()],
})
Enter fullscreen mode Exit fullscreen mode

Snapshot of the demo project #24 - fix with ssr.optimizeDeps + ssr.noExternal

With these combined options above, all the modes work perfectly.

Screenshot of the SSR result with a

A small recap

  1. The resolve.dedupe config will deduplicate the package imports in node_modules without resolving them into relative path imports. This further helps them skipping unnecessary CJS inline transformation in SSR mode.
  2. The resolve.dedupe won't affect the dependencies of external resources. So your target package can still be not singleton. To ensure it, you have to find all the external targets and keep them inlined by ssr.noExternal. You also need to keep them into ssr.optimizeDeps.include if they are in CJS.

The new way to config SSR mode: environments

From Vite 6, the "Environment Instances" and relevant APIs and config options are introduced to better manage the multiple environments like SSR mode in Vite. In the future, the ssr config will be deprecated and replaced by the new environments.ssr config. At the same time, the client-side-only config options can be specified into environments.client. I think this new config design makes more sense. Also, you can have more control over it, e.g. specifying different resolve.dedupe in SPA and SSR mode, etc. (However, till I wrote this article, the resolve.alias config is still global and not environment-specific. Not sure about the reason. Since this could be also very helpful when you'd like to hack some code only in SPA mode or SSR mode.)

Check this official blog post and this official doc for more details.

How to deal with dependencies in Vitest?

Vitest is another Vite family member. It is a unit test framework based on Vite. So it has a similar server environment to execute your code and dependencies for testing. When you run the tests, you will face the same issues like whether a resource should be transformed beforehand or imported directly. Luckily, Vitest also provides such config options. However, their option names are slightly different:

If those 2 options above are not enough, you might need to try option deps.optimizer.

You might be also interested in option deps.web.transformAssets and deps.web.transformCss. However, most of the time, you don't have to worry about them because they are true by default.

Conclusion

So overall, we have:

  1. In normal (SPA) mode, Vite can't deal with CJS code without the optimization process to pre-bundle them into ESM. The optimization process is on for all the code in node_modules by default, but configurable by optimizeDeps option.
  2. In SSR mode, Vite can't deal with non-pure JavaScript code (with CSS imports or assets imports) by default. To make it works, you have to specify them as "inlined" via ssr.noExternal/ssr.noExternal config. For non-pure CJS code, you additionally need ssr.optimizeDeps to pre-bundle them into ESM.
  3. The resolve.alias config will force the matched code to be inlined in SSR mode. So be careful when they are in CJS. Because once they are being inlined, you have to also "optimize" them beforehand.
  4. The resolve.dedupe config won't deduplicate the dependencies in external resources in SSR mode. To make it works, you have to specify all of them as "inlined" and also "optimize" them beforehand necessarily.
  5. Be careful when you deal with nested dependencies via ssr.external/ssr.noExternal and resolve.dedupe.
  6. In Vitest, the corresponding options are server.deps.external/server.deps.inline and deps.optimizer.
  7. The new config in Vite 6: environments.ssr and environments.client.

Why I have to know so much details to use Vite?

I think for most of the common cases, the default settings of Vite are just the best. For example:

  • You usually just want better performance so optimizing all the dependencies makes sense.
  • You usually just consume pure JavaScript code in node_modules and again want better performance so externalizing them by default in SSR mode makes sense.
  • You usually have no large amount of inlined resources in node_modules so no optimization process by default in SSR mode makes sense.
  • You usually can't execute TS/TSX files directly so transforming them in node_modules by default makes sense.

So for simple and fresh projects, you can just go with the default settings. However, when your project grows larger and larger, or as a dev tool author, you may need to deal with all the edge cases sooner or later. So you need to know the necessary details of how Vite works to help you make the right choices. At some rare cases and points, I don't think people could figure them out easily, just by reading the official config references, without diving into the details above.

Once again, after the full integration of Rolldown as its new core, I believe the logic above would be much simpler since it could possibly run to a new trade-off which can resolve and transform all kinds of resources in an organically same way without losing speed. And it might also eliminate most of the edge cases above. Let's keep an eye on that.

However, there might still be some other edge cases that I haven't covered yet. Please DO let us know if you find any. And of course, it's also always good to open a discussion, an issue or a PR in the Vite repo for them.

I will keep this article updated as well.

Cheers!

Comments 0 total

    Add comment