TypeScript

Vite build Unexpected token in node_modules: fix define

Why vite build fails on react-router-dom while vite dev works, which define line triggers it, and a polyfill setup that builds, reproduced on Vite 2.9.12.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

The vite build error comes from your define block. The package named in the message is not broken. In Vite 2, define is a plain text replacement that runs only during the production build. Object values are turned into JSON text and pasted wherever the key appears as a word, in your code and in node_modules. With process: { env: { HOME: './src' } } in define and rollup-plugin-polyfill-node in plugins, the polyfill adds an import line whose binding is called process. The define plugin then rewrites that binding into an object literal, and Rollup's parser stops at column 20. Delete global and process from define, keep only dotted constants such as 'process.env.HOME', and let a polyfill plugin provide the Node globals.

The question is the Stack Overflow question on Vite parser errors in third-party dependencies: a React + TypeScript app on Vite 2.9.12 that runs with npm run dev but fails tsc && vite build. The failure, the negative controls and the fix below were reproduced on 13 October 2026 in the official node:22-bookworm Docker image (Node 22.23.3) with the question's versions: Vite 2.9.12, react-router-dom 6.3.0 and rollup-plugin-polyfill-node 0.9.0.

The error, and why the code frame misleads#

With polyfillNode() in plugins and the question's two define entries, the build fails like this:

text
vite v2.9.12 building for production...
transforming...
✓ 4 modules transformed.
[commonjs] Unexpected token (1:20) in /app/node_modules/react/index.js
error during build:
SyntaxError: Unexpected token (1:20) in /app/node_modules/react/index.js

The question's log names react-router-dom/index.js instead. With an extra CommonJS package in the app, the same plugins and define entries named that package. The message names whichever dependency the parser fails on, not a faulty one. The frame under the message prints line 1 of the original file, and that is misleading: a /** licence comment in the question, 'use strict'; for React in the reproduction. Rollup did not parse that text. It parsed the file after two other plugins had rewritten it. The stack trace gives the useful clue: parseImportSpecifiers, then parseIdent. The parser was inside import { ... } and expected a name.

A small plugin placed after polyfillNode() printed the start of a transformed dependency. Its first line was an import added by the polyfill:

js
import { default as process } from '\0polyfill-node.process';

import { default as is 20 characters long, so 0-based column 20 is where the word process starts. That is the exact position in the error.

Why it happens: Vite 2's define is a build-time text replacement#

Three pieces of Vite 2.9.12's source explain it.

Object values become JSON text. The define plugin stringifies any value that is not already a string:

ts
userDefine[key] = typeof val === 'string' ? val : JSON.stringify(val)

So process: { env: { HOME: './src' } } turns into the text {"env":{"HOME":"./src"}}, and global: {} turns into {}. Each one is pasted, unparenthesised, wherever the key matches as a whole word.

It does nothing in dev. The same file returns early when the command is not a build. Its comment reads for dev we inject actual global defines in the vite client to avoid the transform cost. This is why npm run dev works: no module source is ever rewritten.

It runs after your plugins. In the plugin list, ...normalPlugins (your plugins array, polyfillNode() included) comes immediately before definePlugin(config). The build plugins come after that: commonjsPlugin(options.commonjsOptions), then anything in build.rollupOptions.plugins. The polyfill first adds import { default as process } ... to each dependency that uses process. Define then replaces process inside that import, which leaves import { default as {"env":{"HOME":"./src"}} } from .... The commonjs plugin parses that and fails.

The Vite 2.9 documentation warns about exactly this. From the define section of the v2.9.12 config docs:

Because it's implemented as straightforward text replacements without any syntax analysis, we recommend using define for CONSTANTS only.

It also says that process and global should not be put into this option, and that they can be shimmed or polyfilled instead.

The global: {} line breaks in a second way, which Vite issue #8909 describes. A Vite team member (sapphi-red) showed that if (typeof global !== 'undefined') { global.Prism = Prism; } becomes {}.Prism = Prism. At the start of a statement, { opens a block, not an object, so the line no longer parses. That is why the second answer on the question suggests global: '({})'.

What each config change does, one at a time#

Each row changes one thing from the question's configuration. Every build ran on Vite 2.9.12 in the same container.

ChangeResult
None: global: {} and the process object, polyfillNode() in pluginsUnexpected token (1:20)
Only global: {} removedSame (1:20) error
global set to 'globalThis', process object keptSame (1:20) error
Only the process object removed (global: {} kept)Parse error @:1:24 inside the polyfill's own process shim
polyfillNode() kept only in build.rollupOptions.plugins, define untouchedBuilds, until a dependency uses process (below)

The negative controls matter for the answers on the question. Removing or rewriting global alone does not change the (1:20) error. That error comes from the process object. The global line causes the other failures: the parse error in the process shim, and the {}.Prism statement form. In the question's configuration both lines have to go.

The last row needs a warning. The question already lists polyfillNode() in build.rollupOptions.plugins as well as in plugins. Deleting only the top-level copy lets a project build when no dependency touches process. When a CommonJS dependency calls process.nextTick(...), the process define rewrites that call into {"env":{"HOME":"./src"}}.nextTick(...), and the build fails again in that dependency:

text
[commonjs] Unexpected token (4:8) in /app/node_modules/fake-node-dep/index.js

So the define entries are the cause, whatever happens to the polyfill plugin.

Fix#

1. Take global and process out of define. Keep only real constants, with the full dotted path as the key and a JSON-stringified value. The question needed process.env.HOME for a Segment analytics library, so the entry becomes:

js
// vite.config.js — only constants in define
export default {
  define: {
    'process.env.HOME': JSON.stringify('./src'),
  },
};

For your own code, Vite's own mechanism is import.meta.env with VITE_-prefixed variables. See why import.meta.env is undefined in a production build, which also covers code that still reads process.env.

2. Let one plugin supply the Node globals and modules. The question uses three tools for one job: @esbuild-plugins/node-globals-polyfill and @esbuild-plugins/node-modules-polyfill in optimizeDeps, rollup-plugin-polyfill-node for the build, and resolve aliases to rollup-plugin-node-polyfills. vite-plugin-node-polyfills does all of it as one Vite plugin. Its current release on the npm registry lists every Vite major from 2 to 8 as a supported peer. Install it and remove the others:

bash
npm install --save-dev vite-plugin-node-polyfills
npm uninstall rollup-plugin-polyfill-node @esbuild-plugins/node-globals-polyfill @esbuild-plugins/node-modules-polyfill

Then the whole Node part of the config becomes:

js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
 
export default defineConfig({
  plugins: [react(), nodePolyfills()],
  define: {
    'process.env.HOME': JSON.stringify('./src'),
  },
});

Delete the util, events, stream, path and zlib aliases and the optimizeDeps.esbuildOptions block along with the old plugins. The question also sets build.rollupOptions.output.format to cjs. That has nothing to do with this error, but a browser app normally ships Vite's default ES module output.

Alternative to step 2: if a library only needs global and you would rather keep a define, use a string. This replaces the polyfill plugin instead of adding to it; the combination of both was not built. In the reproduction, a file with the global.Prism = Prism statement from issue #8909 built with either of these, and failed with global: {}:

js
export default {
  define: {
    global: 'globalThis',
  },
};

'globalThis' makes global.Prism refer to the real global object. '({})' builds too, but each occurrence of global evaluates to a new empty object, so a library that writes to global and reads the value back elsewhere will see nothing. The first answer's window.global ||= window; at the top of the entry file has the same goal, without a define.

Verify the fix#

Run the build again. To be sure the polyfills ended up in the bundle, the reproduction added a small CommonJS package under node_modules that calls require('events'), process.nextTick and Buffer.from. With the step 2 config plus build.minify set to false (to inspect the bundle), on Vite 2.9.12:

text
✓ 40 modules transformed.
rendering chunks...
dist/index.html                 0.14 KiB
dist/assets/index.bbec4919.js   310.63 KiB / gzip: 73.40 KiB

That unminified bundle contained the events polyfill's prototype.emit implementation, the process shim's process.nextTick = function(fun), and a const Buffer = buffer.Buffer; binding next to the package's code. Vite did not swap events for its empty browser stub. Then run npm run preview and open the page: a missing polyfill shows up there as a runtime error, which the build alone cannot catch.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

When the error changes after you edit the config#

Two follow-up errors appeared during the reproduction. Each one means a different half of the old setup is still there.

  • 'createContext' is not exported by node_modules/react/index.js, imported by node_modules/react-router/index.js. This happened after define was fixed but rollup-plugin-polyfill-node was left in the top-level plugins array, with or without transformMixedEsModules. React's CommonJS entry now starts with an ES import that the polyfill added before the commonjs plugin ran, and the build no longer finds React's named exports. Remove that plugin and use step 2.
  • Parse error @:1:24 in polyfill-node.process.js. global: {} is still in define, and it is rewriting the polyfill's own shim.

The reproduction's failing configuration (the question's plugins and define entries) builds without the parse error on Vite 8.3.4, which the reproduction also checked. The current define documentation says Vite now performs replacements with Oxc's define feature. Upgrading from Vite 2 is a migration of its own, though. On Vite 2, the version in the question, the documented rule is to keep process and global out of define.

Other "Unexpected token" errors in third-party code have unrelated causes. Jest hits it when it loads an ES module through its CommonJS wrapper, as in the uuid export case. Webpack has its own version of the Node-built-ins-in-the-browser problem, shown in the node:buffer UnhandledSchemeError. For this Vite error, the first thing to check is the define block: the error message points at a dependency, but the code that broke the build is in your own config.

Further reading: Fix Vite [commonjs--resolver] Failed to resolve entry. Also see Fix pnpm Ignored Build Scripts for Native Dependencies. Also see Owl Carousel not working after Vite build: reading 'fn', Vite linked React library: fix Invalid hook call.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote