Understanding how Vite deals with your node_modules

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? 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 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 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 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

Apr 21, 2025 - 03:47
 0
Understanding how Vite deals with your node_modules

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

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

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

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

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>,
)

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=";
// originally 'react'
import react from "/node_modules/.vite/deps/react.js?v=";
// originally 'react-dom/client'
import reactDom_client from "/node_modules/.vite/deps/react-dom_client.js?v=";

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)
);

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
  • In node_modules/foo/package.json:
  {
    "name": "foo"
  }
  • In node_modules/foo/foo-cjs.cjs:
  exports.foo = 'foo-cjs'
  • In node_modules/foo/foo-esm.mjs:
  export const foo = 'foo-esm'

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 });

...

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= but in different ways:

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

// second new import
import { foo as fooEsm } from "/node_modules/.vite/deps/foo_foo-esm__mjs.js?v=";

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-.js?v=";

  // 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();

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-.js?v=";

  // node_modules/foo/foo-esm.mjs
  var foo = "foo-esm";
  export {
    foo
  };

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

And the __commonJS() helper looks like this:

  • node_modules/.vite/deps/chunk-.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 };

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
  • In node_modules/foo/foo-cjs-module.cjs:
  module.exports = 'foo-cjs-module'
  • And then add the import into src/main.tsx:
  import fooCjsModule from 'foo/foo-cjs-module.cjs'

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=";
const fooCjsModule = fooImport.__esModule ? fooImport.default : fooImport;

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-.js?v=";

  // 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();

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'

will be resolved into:

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

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(),
  ],
})

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'

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=";
import { foo as fooCjs } from "/node_modules/foo/foo-cjs.cjs?import&v=";
import { foo as fooEsm } from "/node_modules/foo/foo-esm.mjs?v=";
import fooCjsAll from "/node_modules/foo/foo-cjs.cjs?import&v=";
import fooCjsModule from "/node_modules/foo/foo-cjs-module.cjs?import&v=";

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(),
  ],
})

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(),
  ],
})

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(),
  ],
})

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
  • In node_modules/foo/node_modules/foo-dep-a/package.json:
  {
    "name": "foo-dep-a"
  }
  • In node_modules/foo/node_modules/foo-dep-a/foo-dep-a-cjs.cjs:
  exports.fooDepA = 'foo-dep-a-cjs'
  • 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

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()],
})

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
    as
    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 />);
  }
  • src/server.tsx:
  import { renderToString } from "react-dom/server";
  import App from './App';

  export const render = async () =>
    renderToString(<App />);
  • 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(``, 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}`);
  });

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