Skip to content

Fix: Module parse failed: Unexpected token (Webpack / Vite / esbuild)

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix 'Module parse failed: Unexpected token' in Webpack, Vite, and esbuild by configuring the correct loaders and transforms for JSX, TypeScript, CSS, JSON, and other file types.

The Error

You run your build or start your dev server and hit one of these:

Webpack (Create React App, Next.js, or custom config):

Module parse failed: Unexpected token (12:4)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file.
Module parse failed: Unexpected token (5:16)
File was processed with these loaders:
 * ./node_modules/babel-loader/lib/index.js
You may need an additional loader to handle the result of these loaders.

Vite (dev server or build):

[vite] Internal server error: Failed to parse source for import analysis because the content contains invalid JS syntax. If you are using JSX, make sure to name the file with the .jsx or .tsx extension.

esbuild:

ERROR: Unexpected ">"
   src/App.js:8:12:
     8 │     return <div className="app">
       ╵             ^
ERROR: The JSX syntax extension is not currently enabled

All of these point to the same root problem: your bundler tried to parse a file but encountered syntax it does not understand. The file contains syntax (JSX, TypeScript, optional chaining, CSS, or something else entirely) that requires a loader, plugin, or transform to convert it into plain JavaScript before the bundler can process it.

Why This Happens

Bundlers do not understand every syntax out of the box. They parse files as plain JavaScript by default. When a file contains syntax that falls outside standard JavaScript — such as JSX angle brackets, TypeScript type annotations, CSS rules, or even newer ECMAScript proposals — the parser chokes on the first token it cannot recognize.

To handle non-standard syntax, bundlers rely on loaders (Webpack), plugins (Vite), or loader configuration (esbuild) to transform source code before parsing. Each file type needs a matching transform:

  • JSX/TSX needs Babel, SWC, or esbuild to compile angle bracket syntax into React.createElement calls (or the JSX runtime equivalent).
  • TypeScript needs ts-loader, babel-loader with @babel/preset-typescript, or esbuild’s built-in TypeScript support to strip type annotations.
  • CSS/SCSS/Less needs css-loader, style-loader, or PostCSS to be handled as non-JavaScript assets.
  • JSON is usually handled natively, but misconfiguration can break it.
  • Images, SVGs, fonts need asset loaders or type declarations.

When the appropriate loader is missing, misconfigured, or applied to the wrong file pattern, the bundler falls back to raw JavaScript parsing and fails at the first unfamiliar token.

Why this matters: The “Unexpected token” error does not mean your code has a syntax error. It means the bundler’s parser does not recognize the syntax because the right transform is not configured. The fix is always about configuring loaders, not changing your source code.

Fix 1: Add a Loader for JSX / TSX Files

This is the single most common cause. You have a .js or .jsx file containing JSX syntax, but no Babel or SWC loader is configured to transform it.

Webpack with Babel

Install the required packages:

npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/preset-react

Add the loader rule to your Webpack config:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
};

Webpack with SWC

SWC is significantly faster than Babel. Install swc-loader:

npm install --save-dev swc-loader @swc/core
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'swc-loader',
          options: {
            jsc: {
              parser: {
                syntax: 'ecmascript',
                jsx: true,
              },
              transform: {
                react: {
                  runtime: 'automatic',
                },
              },
            },
          },
        },
      },
    ],
  },
};

Vite

Vite uses esbuild internally and handles JSX automatically — but only for files with .jsx or .tsx extensions. If your JSX lives in .js files, rename them to .jsx, or configure Vite’s esbuild options:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  esbuild: {
    loader: 'jsx',
    include: /\.js$/,
  },
});

If you also need to handle .js files that do not contain JSX, use the esbuild.include pattern to target only the relevant files or directories.

Fix 2: Configure TypeScript Support

TypeScript files contain type annotations (interface, type, generics like <T>) that are not valid JavaScript. Without a TypeScript-aware loader, the parser fails on the first type annotation.

Webpack with ts-loader

npm install --save-dev ts-loader typescript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        use: 'ts-loader',
      },
    ],
  },
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
  },
};

Webpack with Babel

If you prefer Babel for TypeScript (faster, but no type checking during build):

npm install --save-dev @babel/preset-typescript

Add the preset to your Babel config:

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react",
    "@babel/preset-typescript"
  ]
}

And update the Webpack rule to match .ts and .tsx files:

{
  test: /\.(js|jsx|ts|tsx)$/,
  exclude: /node_modules/,
  use: 'babel-loader',
}

Make sure your tsconfig.json exists and has valid configuration. A minimal setup for a React project:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

If TypeScript type errors are also showing up in your build, see Fix: Type ‘X’ is not assignable to type ‘Y’ in TypeScript for common type-level fixes.

Fix 3: Add CSS / SCSS / Less Loaders

Importing a CSS file directly into JavaScript (import './styles.css') fails with “Unexpected token” in Webpack if the CSS loaders are not installed.

npm install --save-dev css-loader style-loader
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

For SCSS:

npm install --save-dev sass-loader sass css-loader style-loader
{
  test: /\.scss$/,
  use: ['style-loader', 'css-loader', 'sass-loader'],
}

For Less:

npm install --save-dev less-loader less css-loader style-loader
{
  test: /\.less$/,
  use: ['style-loader', 'css-loader', 'less-loader'],
}

Important: The order of loaders in the use array matters. Webpack applies them right to left. So sass-loader runs first (compiling SCSS to CSS), then css-loader (resolving @import and url()), then style-loader (injecting CSS into the DOM).

Vite handles CSS, SCSS, and Less natively. If you see a parse error for CSS in Vite, the problem is usually that the file is being treated as JavaScript — check the file extension and import path.

Fix 4: Fix JSON Import Issues

Webpack 5 handles JSON imports natively. However, if you have a custom rule that matches .json files or a misconfigured type field, JSON parsing can break:

// Wrong -- this treats JSON as JavaScript
{
  test: /\.json$/,
  type: 'javascript/auto',
  use: 'some-loader',
}

Remove any custom JSON rules unless you have a specific need. If you do need to customize JSON handling, use type: 'json':

{
  test: /\.json$/,
  type: 'json',
}

If you are importing JSON from an API endpoint or a generated file that has a non-standard extension (like .geojson), add it to the JSON rule:

{
  test: /\.(json|geojson)$/,
  type: 'json',
}

In esbuild, JSON is supported by default. If it is failing, make sure the file is valid JSON — a trailing comma or a comment in the JSON file will cause a parse failure.

Fix 5: Update Parser for Optional Chaining and Nullish Coalescing

If you see “Unexpected token” on a line containing ?. (optional chaining) or ?? (nullish coalescing), your parser or bundler target is too old. These operators are part of ES2020 and are supported natively in all modern environments, but older Babel or Webpack configurations might not handle them.

Babel

Make sure @babel/preset-env is up to date:

npm install --save-dev @babel/core@latest @babel/preset-env@latest

If you are pinned to an older version, you can add the specific plugins:

npm install --save-dev @babel/plugin-proposal-optional-chaining @babel/plugin-proposal-nullish-coalescing-operator
{
  "plugins": [
    "@babel/plugin-proposal-optional-chaining",
    "@babel/plugin-proposal-nullish-coalescing-operator"
  ]
}

Webpack’s acorn parser

If the error comes from Webpack’s own parser (not from a loader), make sure your Webpack version is at least 5.x. Webpack 4’s parser does not support ES2020 syntax natively; it relies on Babel to downlevel the code first. Upgrade to Webpack 5 or ensure all .js files pass through babel-loader before Webpack tries to parse them.

Fix 6: Handle Non-JS File Imports (Images, SVG, Fonts)

Importing images or fonts directly causes a parse failure if there is no matching loader:

import logo from './logo.svg';
import heroImage from './hero.png';

Webpack 5

Use asset modules (no additional packages needed):

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif|webp)$/,
        type: 'asset/resource',
      },
      {
        test: /\.svg$/,
        type: 'asset/resource',
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        type: 'asset/resource',
      },
    ],
  },
};

If you want to use SVGs as React components, install @svgr/webpack:

npm install --save-dev @svgr/webpack
{
  test: /\.svg$/,
  use: ['@svgr/webpack'],
}

esbuild

esbuild needs explicit loader mappings for non-JS file types:

// esbuild.config.js
require('esbuild').build({
  entryPoints: ['src/index.tsx'],
  bundle: true,
  loader: {
    '.png': 'file',
    '.jpg': 'file',
    '.svg': 'file',
    '.woff': 'file',
    '.woff2': 'file',
  },
  outdir: 'dist',
});

If you run into related issues where the build completes but the application fails to load assets at runtime, the cause is almost always a hashed filename mismatch between the HTML and the deployed chunk — verify the publicPath value matches the URL prefix your asset host actually serves.

Fix 7: Transpile Dependencies in node_modules

By default, Webpack’s babel-loader excludes node_modules. This works for most packages because they ship pre-compiled JavaScript. But some packages ship untranspiled source code (ESM-only packages, packages with JSX, or packages targeting modern syntax).

When Webpack tries to parse these packages without a loader, you get “Module parse failed.”

Webpack

Modify the exclude pattern to allow specific packages through:

{
  test: /\.(js|jsx)$/,
  exclude: /node_modules\/(?!(some-esm-package|another-package)\/).*/,
  use: 'babel-loader',
}

Or switch from exclude to include:

{
  test: /\.(js|jsx)$/,
  include: [
    path.resolve(__dirname, 'src'),
    path.resolve(__dirname, 'node_modules/some-esm-package'),
  ],
  use: 'babel-loader',
}

Next.js

Next.js provides transpilePackages in next.config.js:

// next.config.js
module.exports = {
  transpilePackages: ['some-esm-package', 'another-package'],
};

This is the cleanest solution if you are using Next.js. It ensures the specified packages pass through the same SWC/Babel pipeline as your own source code.

Real-world scenario: You install a modern ESM-only charting library like chart.js v4, and Webpack throws “Unexpected token” on an export statement inside node_modules. The library ships untranspiled ES modules, so you need to either add it to transpilePackages (Next.js) or include its path in your babel-loader rule.

If the build fails entirely with an exit code error, see Fix: npm ERR! code ELIFECYCLE for broader build failure debugging.

Fix 8: Add vue-loader for Vue Single File Components

Vue .vue files contain <template>, <script>, and <style> blocks in a single file. Without vue-loader, Webpack treats the file as JavaScript and fails on the <template> tag.

npm install --save-dev vue-loader vue-template-compiler
// webpack.config.js
const { VueLoaderPlugin } = require('vue-loader');

module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new VueLoaderPlugin(),
  ],
};

For Vue 3, use vue-loader v17+ and @vue/compiler-sfc instead of vue-template-compiler:

npm install --save-dev vue-loader@next @vue/compiler-sfc

Vite handles .vue files through @vitejs/plugin-vue:

npm install --save-dev @vitejs/plugin-vue
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
});

Fix 9: Add @babel/preset-env for Modern JavaScript Syntax

If you have babel-loader installed but are missing @babel/preset-env, Babel will run but won’t actually transform modern syntax. The result: Babel outputs the same modern syntax it received, and Webpack’s parser may still choke on it (especially in Webpack 4).

Make sure your Babel config includes the preset:

{
  "presets": ["@babel/preset-env"]
}

Check all places where Babel config can live — any of these can override or shadow each other:

  • babel.config.js or babel.config.json (project-wide)
  • .babelrc or .babelrc.json (directory-specific)
  • "babel" key in package.json
  • Inline options in babel-loader’s Webpack config

If you have multiple config files, Babel’s configuration merging can produce unexpected results. Consolidate into a single babel.config.js at the project root when possible.

Fix 10: Configure Vite’s esbuild Target

Vite uses esbuild for development and Rollup for production builds. If your code or a dependency uses syntax that esbuild does not support at the configured target, you will see parse errors.

Set the esbuild target

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  esbuild: {
    target: 'es2020',
  },
  build: {
    target: 'es2020',
  },
});

Common target values: es2015, es2017, es2020, es2022, esnext. Use esnext during development for maximum speed (no downleveling), and a specific target for production to match your browser support requirements.

JSX in .js files

Vite enforces that JSX syntax must live in .jsx or .tsx files. If you have a .js file containing JSX (common in older React projects), you have two options:

  1. Rename the files from .js to .jsx. This is the recommended approach.
  2. Configure esbuild to treat .js files as JSX (shown in Fix 1 above).

esbuild does not support certain proposals

esbuild does not support every TC39 proposal. Decorators (legacy or stage 3), for example, require a plugin or a different build path. If esbuild fails on decorator syntax, use the Vite plugin @vitejs/plugin-react with SWC or Babel, which can handle decorators:

npm install --save-dev @vitejs/plugin-react
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['@babel/plugin-proposal-decorators', { version: '2023-11' }]],
      },
    }),
  ],
});

Fix 11: Configure esbuild Loader Mapping

When using esbuild directly (not through Vite), you must explicitly map file extensions to loaders:

require('esbuild').build({
  entryPoints: ['src/index.tsx'],
  bundle: true,
  outdir: 'dist',
  loader: {
    '.ts': 'ts',
    '.tsx': 'tsx',
    '.js': 'js',
    '.jsx': 'jsx',
    '.css': 'css',
    '.json': 'json',
    '.png': 'file',
    '.svg': 'file',
  },
});

If you are getting “Unexpected token” for a specific file type, check whether the corresponding loader is listed. The available esbuild loaders are: js, jsx, ts, tsx, css, json, text, binary, file, dataurl, base64, copy, and default.

For TypeScript files that also contain JSX, use the tsx loader, not ts. The ts loader does not enable JSX parsing:

loader: {
  '.tsx': 'tsx',  // TypeScript + JSX
  '.ts': 'ts',    // TypeScript only
}

If your project uses .ts files that contain JSX (a non-standard pattern), force them through the tsx loader:

loader: {
  '.ts': 'tsx',
}

How other bundlers handle this same problem

The “Module parse failed” wording is a Webpack signature, but every JavaScript bundler refuses to parse syntax it does not understand. Knowing the defaults of each tool tells you whether you need a loader, a plugin, an extension rename, or just a config flag.

Webpack loaders. Webpack has the smallest default surface. Out of the box it parses plain JavaScript and JSON. Everything else — JSX, TypeScript, CSS, images, fonts, .vue, .svelte, MDX — requires an explicit rules entry pointing at a loader. The upside is total control over the pipeline; the downside is the boilerplate this article exists to solve.

Vite plugins. Vite parses JSX in .jsx/.tsx and TypeScript in .ts/.tsx natively through esbuild during dev, then through Rollup for production. CSS, JSON, and static assets are handled by built-in plugins. Frameworks like React, Vue, and Svelte ship official plugins (@vitejs/plugin-react, @vitejs/plugin-vue, @sveltejs/vite-plugin-svelte) that you register once. Vite’s strictest rule is “JSX must live in .jsx or .tsx” — copying a Create React App project that puts JSX in .js files into Vite reproduces this article’s error immediately.

Parcel. Parcel auto-detects file types and pulls in the transformer it needs based on the file extension and content. There is no explicit config for the common cases — Parcel handles JSX, TSX, CSS, SCSS, Less, images, fonts, and WebAssembly without any rules. The trade-off is less control over the order of transforms and harder debugging when the detection picks the wrong one. The Parcel equivalent of this error is usually Could not statically evaluate ... or a plugin-specific failure.

Rspack. Rspack is a Rust rewrite of Webpack that aims to be config-compatible. It supports the same module.rules shape but ships with built-in SWC for JS/TS/JSX, so you typically do not need babel-loader or swc-loader at all — just set builtin:swc-loader or omit the loader entry. “Module parse failed” still appears for unhandled file types, with the same fix surface as Webpack.

esbuild. esbuild parses JS, JSX, TS, TSX, CSS, JSON, plain text, binary, and file (asset) loaders. It does not support legacy decorators, the stage-3 decorators proposal, or every TC39 syntax — adopt a Babel or SWC plugin path for those. JSX in .js requires an explicit loader override (see Fix 1). esbuild is also strict about TypeScript: it strips types but does not type-check, so semantic TypeScript errors slip through.

SWC. SWC is the Rust-based transpiler used by Next.js, Parcel, Rspack, Deno, and swc-loader under Webpack. It supports JS, JSX, TS, TSX, and a configurable set of proposals via jsc.parser and jsc.transform. SWC does not bundle on its own — it is the transpiler half — but it is responsible for most “Unexpected token” failures inside Next.js. The fix is usually flipping jsc.parser.syntax from "ecmascript" to "typescript" or enabling jsx: true.

Default file-type table. Use this when you switch tools and want to know what works without setup:

BundlerJSX in .jsTSCSS importSVG as componentDecorators
Webpackneeds loaderneeds loaderneeds loaderneeds @svgr/webpackneeds Babel plugin
Viteneeds config overrideyesyesneeds vite-plugin-svgrneeds Babel via plugin-react
Parcelyesyesyesyes (auto)yes (with config)
Rspackyes (SWC)yes (SWC)yesneeds @svgr/webpackyes via SWC
esbuildneeds loader: 'jsx'yesyes (basic)as fileno
SWCyesyesn/a (transpiler)n/ayes

When migrating between bundlers, expect to convert loader rules to plugin objects and to either rename files or add equivalent file-type configuration. The error message changes, but the underlying question — “does this tool know how to parse this syntax?” — stays identical.

Still Not Working?

Check the exact file and line number

The error message includes the file path and line number where parsing failed. Open that file and look at the exact line. Common patterns:

  • Angle bracket < — JSX without the right loader.
  • Colon after a variable name (x: string) — TypeScript without the right loader.
  • At sign @ — Decorator syntax without a decorator plugin.
  • Hash # — Private class fields with an outdated parser.
  • CSS selectors (.class, @media) — CSS file being parsed as JS.

Verify which loader actually runs

In Webpack, the error message tells you which loaders processed the file. If you see “File was processed with these loaders” followed by a list, the issue is not that loaders are missing but that the loaders that ran did not fully transform the syntax. You may need an additional loader or a missing Babel preset.

Loader ordering matters

In Webpack, loaders in the use array run from right to left (bottom to top). If you have both babel-loader and ts-loader, ts-loader should run first (rightmost) to strip TypeScript, then babel-loader processes the resulting JavaScript:

{
  test: /\.tsx?$/,
  use: ['babel-loader', 'ts-loader'],  // ts-loader runs first
}

Conflicting rules matching the same file

If two Webpack rules match the same file extension, both apply. This can cause unexpected behavior if one rule handles the file correctly but another interferes. Check all rules in your config and in any merged configs (like from webpack-merge).

The file is actually invalid

Sometimes the file genuinely contains a syntax error — an unclosed bracket, a stray character from a bad merge, or corrupted content. Open the file at the reported line and verify the syntax is correct. Running a standalone parser or linting the file can help. If you encounter issues where the import itself resolves but the module fails to load in the browser, see Fix: Error Cannot find module in Node.js for server-side variants of this problem.

node_modules packages shipping TypeScript or JSX source

Some newer packages ship .ts or .tsx source files without pre-compiling them. This is becoming more common in the ecosystem. Check the package’s main, module, or exports field in its package.json to see what file it points to. If it points to a .ts file, you need to include that package in your loader’s processing scope (see Fix 7).

Webpack version mismatch with loaders

Make sure your loader versions are compatible with your Webpack version. babel-loader 9.x requires Webpack 5. css-loader 7.x requires Webpack 5. Using Webpack 4 with loaders that target Webpack 5 can cause cryptic parse errors.

Check compatibility:

npm ls webpack babel-loader css-loader ts-loader

If you see peer dependency warnings, align the versions. For broader dependency resolution issues, see Fix: Module not found: Can’t resolve which covers module resolution in depth.

Mismatch between module and moduleResolution in tsconfig

A tsconfig.json that pairs "module": "CommonJS" with "moduleResolution": "bundler" (or vice versa) can emit import.meta, export {} re-exports, or top-level await in places your bundler’s parser does not accept. Match the two values: "module": "ESNext" with "moduleResolution": "bundler" for Vite/Webpack 5 projects, or "module": "NodeNext" with "moduleResolution": "NodeNext" for Node-targeted libraries.

Source maps from a previous build are being parsed as JS

If a .map file accidentally ends up in src/ or in a node_modules package’s exports field, the bundler may try to parse it as JavaScript. The file is valid JSON, but bundlers reading it as ES modules choke on the first {. Exclude .map files from your include patterns and check the package’s exports field for a typo.

module field points to ESM but main is CJS

Some libraries ship dual builds. If package.json lists "main": "dist/index.cjs" and "module": "dist/index.esm.js" but the bundler picks the wrong one for your environment, you can end up parsing ESM syntax through a CJS parser (or the reverse). Force the resolution with Webpack’s resolve.mainFields: ['module', 'main'] or Vite’s resolve.conditions.

Worker or service-worker file is in the regular pipeline

new Worker(new URL('./worker.ts', import.meta.url)) requires the worker plugin or Webpack 5’s worker support. Without it, the worker file is processed by the main rules and may fail to parse if it uses syntax the main pipeline does not allow. Use worker-loader (Webpack 4), Webpack 5’s built-in worker handling, or Vite’s ?worker query import to route worker files through the correct pipeline.


Related:

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles