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 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.
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=<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)
);
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=<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>";
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();
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
};
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 };
So overall:
- 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.
- For ESM package import, it's straightforward, nothing special to do.
- 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 betweenexports.foo = xxx
andmodule.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=<hash>";
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-<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();
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=<hash>";
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
- All the package imports will be resolved into imports to a path like
/node_modules/.vite/deps/xxx
. - For ESM package import, it's more straightforward, almost nothing extra.
- 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:
- Traverse all the source code from the entry file of your project (usually it's the
index.html
) and find out all the imports tonode_modules
. and then draw a "optimization boundary" around them.- e.g. in the project above, the analyzation prcess would be:
-
index.html
: importssrc/main.tsx
-
src/main.tsx
: importsreact/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
: importsreact/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
-
- e.g. in the project above, the analyzation prcess would be:
- 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:
- To support CJS package imports in the browser.
- 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=<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>";
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:
- 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
. - Option
optimizeDeps.noDiscovery
can be set to disable the default deps analyzation. - Option
optimizeDeps.include
andoptimizeDeps.exclude
are used to opt-in/opt-out certain package imports to/from the "optimization boundary". - Command
vite optimize
andvite --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.
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:
- Create 2 entries:
src/client.tsx
andsrc/server.tsx
instead of the originalsrc/main.tsx
. - Update
index.html
to includesrc/client.tsx
instead ofsrc/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. - Add a
run.mjs
file to run the whole service. - Add a
dev:ssr
npm scripts inpackage.json
withnode ./run.mjs
. - And don't forget to install
express
andcompression
which are used inrun.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(`<!--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}`);
});
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:
- JavaScript files 🆚 other files like CSS files, TypeScript file, JSX files, etc.
- in ESM 🆚 in CJS, if it's a JavaScript file.
- 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
- In
node_modules/bar/package.json
:
{
"name": "bar"
}
-
node_modules/bar/bar-esm.mjs
:
import './style.css'
export const bar = 'bar'
-
node_modules/bar/style.css
:
.bar {
background-color: red;
}
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>)
}
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
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(),
],
})
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:
- git clone the Vite source code, install all the dependencies, modify something, rebuild it, and then link it to your project.
- 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 innode_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)
}
}
It's easy to imagine that:
- For
external
packages, it will go to therunExternalModule()
method which is the nativeimport
statement in server environment like Node.js. - For
noExternal
packages, it will go to therunInlinedModule()
method which is a customAsyncFunction
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
According to the printed logs, now we know:
- The runtime error was caused by the 5th request, which
import(./node_modules/bar/bar-esm.mjs)
natively, whilebar-esm.mjs
is non-pure JavaScript. - 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 nativeawait import()
inside. So basically, it turns the static imports into dynamic ones. - Also, the helper function
await __vite_ssr_import__()
has a second parameter which accepts an object withimportedNames
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
If you look carefully, you will find:
- The 5th request becomes inlined which further makes the CSS import as the 6th request as an inlined resource. But more importantly,
- 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
- In
node_modules/bar/node_modules/bar-sub/package.json
:
{
"name": "bar-sub"
}
- In
node_modules/bar/node_modules/bar-sub/bar-sub-cjs.cjs
:
exports.barSub = 'bar-sub-cjs'
- And then update
node_modules/bar/bar-esm.mjs
to importbar-sub
:
import { barSub } from 'bar-sub/bar-sub-cjs.cjs'
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',
}
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;
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' } }
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' }
// }
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;
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
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'],
},
}
or:
{
ssr: {
// all the packages will be transformed, except...
noExternal: true,
// only 'react' and 'react-dom' will be executed directly
external: ['react', 'react-dom'],
},
}
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'],
},
}
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:
- 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. - if a package has been specified as
ssr.noExternal
, all its sub-dependencies will still be treated according to thessr.external
andssr.noExternal
settings.
For example, if the dependency tree looks like:
bar
└── bar-dep-a
└── bar-dep-b
Then:
-
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-dependencybar-dep-a
won't take effect. -
config:
{ ssr: { external: true, noExternal: ['bar'], }, }
will make
bar
inline, butbar-dep-a
andbar-dep-b
still external. -
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
-
node_modules/bar/bar-tsx.tsx
:
import './style.css'
export const bar = 'bar'
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:
- Vite will treat all the JavaScript files in
node_modules
as external by default, whatever it's in CJS or ESM. -
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. - 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. - If you set a package as "external", all its sub-dependencies will be treated as external as well. You can't change it anymore.
- If you set a package as "noExternal", all its sub-dependencies will be also treated according to the
ssr.external
andssr.noExternal
settings. - You can either set
ssr.external
orssr.noExternal
totrue
and then specify certain packages in the other one. Forssr.noExternal
, you can also use a regular expression to match the package names. - If you want to inline a certain package and all its deep dependencies, you have to set
ssr.noExternal
totrue
or explicitly list all of them inssr.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
- In
node_modules/baz/package.json
:
{
"name": "baz"
}
-
node_modules/baz/baz-cjs.cjs
:
require('./style.css')
exports.baz = 'baz'
-
node_modules/baz/style.css
:
.baz {
color: white;
background-color: green;
}
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>)
}
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)
...
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()],
})
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-bundledbaz/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
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"]});
...
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"]});
...
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
- For pure ESM code, you can go anywhere.
- For pure CJS code, you can go to
ssr.external
. Vite will load them directly. - For ESM code with CSS imports or assets imports, you can go to
ssr.noExternal
. Vite will transform them before execution. - For CJS code with CSS imports or assets imports, you can go to both
ssr.noExternal
andssr.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.
Also remember:
-
optimizeDeps
is enabled by default in client-side. You can disable it by settingoptimizeDeps.noDiscovery
. -
optimizeDeps
is disabled by default in server-side. -
external
is by enabled default in SSR mode. You can disable it by settingssr.noExternal
astrue
.
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
- In
node_modules/bar/node_modules/foo/package.json
:
{
"name": "foo",
"version": "2.0.0"
}
- In
node_modules/bar/node_modules/foo/foo-cjs.cjs
:
exports.foo = 'foo-cjs-v2'
- next, update
node_modules/bar/bar-esm.mjs
to consumefoo
:
import './style.css'
import { foo } from 'foo/foo-cjs.cjs'
export const bar = foo
- revert the
src/App.tsx
to consumeBar
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>)
}
- revert the
vite.config.ts
to markbar
as inlined:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
ssr: {
noExternal: ['bar'],
},
plugins: [
react(),
],
})
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(),
],
})
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)
...
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"]});
- 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"]});
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
- The
resolve.alias
config will affect the package imports innode_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(),
],
})
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"]});
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
}
...
}
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
- In
node_modules/qux/package.json
:
{
"name": "qux"
}
-
node_modules/qux/qux-cjs.cjs
:
const { foo } = require('foo/foo-cjs.cjs')
exports.qux = foo
- In
node_modules/qux/node_modules/foo/package.json
:
{
"name": "foo",
"version": "3.0.0"
}
-
node_modules/qux/node_modules/foo/foo-cjs.cjs
:
exports.foo = 'foo-cjs-v3'
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>)
}
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).
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()],
})
Snapshot of the demo project #24 - fix with ssr.optimizeDeps + ssr.noExternal
With these combined options above, all the modes work perfectly.
A small recap
- The
resolve.dedupe
config will deduplicate the package imports innode_modules
without resolving them into relative path imports. This further helps them skipping unnecessary CJS inline transformation in SSR mode. - 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 byssr.noExternal
. You also need to keep them intossr.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:
-
server.deps.external
: to specify the packages being imported directly. -
server.deps.inline
: to specify the packages being transformed beforehand.
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:
- 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 byoptimizeDeps
option. - 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 needssr.optimizeDeps
to pre-bundle them into ESM. - 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. - 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. - Be careful when you deal with nested dependencies via
ssr.external
/ssr.noExternal
andresolve.dedupe
. - In Vitest, the corresponding options are
server.deps.external
/server.deps.inline
anddeps.optimizer
. - The new config in Vite 6:
environments.ssr
andenvironments.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!