How to Fix Webpack 'Configuration Object Does Not Match API Schema' After Adding react-refresh
Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 10–20 mins
TL;DR
- What broke:
react-refreshwas wired into the Babel config (or webpack plugins array) incorrectly — either instantiated withoutnew, missing its pairedReactRefreshWebpackPlugin, or not guarded behind aisDevelopmentconditional — causing webpack's internal AJV schema validator to reject the entire config object at startup. - How to fix it: Install
@pmmmwh/react-refresh-webpack-plugin, instantiate it withnew, and wrap both the Babel plugin and the webpack plugin inside aisDevelopmentguard so they never run in production builds. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your
webpack.config.jsand get a corrected diff without sending your config to any external server.
The Incident (What Does the Error Mean?)
You ran webpack serve or webpack build and got slapped with this wall of red:
Invalid configuration object. Webpack has been initialized using a
configuration object that does not match the API schema.
- configuration.plugins[2] should be one of these:
object { apply, … } | function
-> Plugin Instance (with an apply function) or a class (which is applied on instantiation)
or a variant like:
TypeError: Cannot read properties of undefined (reading 'apply')
at webpack (node_modules/webpack/lib/webpack.js:...)
or, after adding the Babel plugin entry:
Error: [react-refresh/babel] This plugin should not be used in a production environment.
Immediate consequence: webpack refuses to initialize. Zero bundles are emitted. Your dev server never starts. CI pipeline fails on the very first step. Every developer on the team is blocked.
The schema validator webpack uses (AJV under the hood) is strict. It expects every entry in plugins[] to be an object with an apply method — i.e., a properly instantiated plugin class. A raw class reference, undefined, or a plain object without apply causes an immediate hard rejection before a single module is resolved.
The Attack Vector / Blast Radius
This is a build-system availability failure, not a runtime security exploit — but the blast radius is wider than it looks:
Total CI/CD pipeline blockage. Every PR build fails. Merge queues back up. If your team ships multiple times per day, this is a direct velocity tax measured in engineer-hours per hour of downtime.
Production config contamination. The most dangerous variant:
react-refresh/babelgets accidentally left in the Babel config for production builds. React Refresh injects a runtime module (react-refresh/runtime) into every component file. That module is ~40 KB unminified, contains HMR socket logic, and throws an explicit error in production becausewindow.__reactRefreshInjectedis never set by the absent webpack plugin. Result: your production bundle crashes on load for every user.Dependency version skew.
react-refresh(the Babel transform) and@pmmmwh/react-refresh-webpack-plugin(the webpack plugin) must be version-compatible. Mismatched versions silently produce a plugin object that fails schema validation only at runtime, not atnpm installtime — so your lockfile looks clean but your build is broken.Monorepo hoisting collisions. In yarn/pnpm workspaces,
react-refreshcan be hoisted to the root while the webpack plugin resolves a different copy from a nestednode_modules. The schema error is the symptom; the root cause is two incompatible instances.
How to Fix It (The Solution)
Step 0 — Install the missing paired package
npm install --save-dev @pmmmwh/react-refresh-webpack-plugin react-refresh
# or
yarn add -D @pmmmwh/react-refresh-webpack-plugin react-refresh
If you only installed react-refresh and added the Babel plugin, you have half the system. The webpack plugin is mandatory.
Basic Fix — webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
+const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
+
+const isDevelopment = process.env.NODE_ENV !== 'production';
module.exports = {
mode: 'development',
entry: './src/index.js',
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
plugins: [
- 'react-refresh/babel',
+ isDevelopment && 'react-refresh/babel',
- ],
+ ].filter(Boolean),
},
},
],
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: './public/index.html' }),
- ReactRefreshWebpackPlugin,
+ isDevelopment && new ReactRefreshWebpackPlugin(),
- ],
+ ].filter(Boolean),
devServer: {
- hot: false,
+ hot: true,
},
};
Three rules to memorize:
- Always
new ReactRefreshWebpackPlugin()— never pass the class reference bare. - Always guard both the Babel plugin and the webpack plugin behind
isDevelopment. - Always
.filter(Boolean)the plugins array sofalseentries don't leak through.
Enterprise Best Practice — Split Config Files
For teams running webpack in CI with multiple environments, inline process.env.NODE_ENV ternaries become unmaintainable. Use webpack-merge:
// webpack.common.js — no react-refresh here at all
module.exports = {
entry: './src/index.js',
module: { rules: [ /* base rules, no babel react-refresh plugin */ ] },
plugins: [ new HtmlWebpackPlugin({ template: './public/index.html' }) ],
};
// webpack.dev.js
+const { merge } = require('webpack-merge');
+const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
+const common = require('./webpack.common.js');
+
+module.exports = merge(common, {
+ mode: 'development',
+ devServer: { hot: true },
+ module: {
+ rules: [
+ {
+ test: /\.[jt]sx?$/,
+ exclude: /node_modules/,
+ use: [{ loader: 'babel-loader', options: { plugins: ['react-refresh/babel'] } }],
+ },
+ ],
+ },
+ plugins: [new ReactRefreshWebpackPlugin()],
+});
// webpack.prod.js
+const { merge } = require('webpack-merge');
+const common = require('./webpack.common.js');
+
+module.exports = merge(common, {
+ mode: 'production',
+ // react-refresh is structurally absent — no conditional needed
+});
This eliminates the entire class of "forgot the isDevelopment guard" bugs because react-refresh is architecturally isolated to the dev config file.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. Validate webpack config as a pre-commit hook
Add a package.json script that dry-runs webpack config validation:
{
"scripts": {
"validate:webpack": "webpack --config webpack.dev.js --dry-run 2>&1 | grep -i 'error\|schema' && exit 1 || exit 0"
}
}
Hook it with husky:
npx husky add .husky/pre-commit "npm run validate:webpack"
2. Enforce environment separation with ESLint
Use eslint-plugin-node or a custom rule to flag require('react-refresh') appearing in any file not matching *.dev.* or webpack.dev.js:
{
"rules": {
"no-restricted-modules": ["error", {
"paths": [{
"name": "react-refresh/babel",
"message": "react-refresh/babel must only appear in webpack.dev.js or a dev-only Babel config."
}]
}]
}
}
3. Pin compatible versions with Renovate/Dependabot grouping
In renovate.json, group react-refresh and @pmmmwh/react-refresh-webpack-plugin so they are always updated together:
{
"packageRules": [
{
"matchPackageNames": ["react-refresh", "@pmmmwh/react-refresh-webpack-plugin"],
"groupName": "react-refresh bundle",
"automerge": false
}
]
}
4. Schema validation in CI with webpack-cli --json
# .github/workflows/build.yml
- name: Validate webpack schema
run: |
npx webpack --config webpack.dev.js --json > /dev/null
npx webpack --config webpack.prod.js --json > /dev/null
env:
NODE_ENV: production
This catches schema errors on every PR before a single test runs, making the feedback loop seconds instead of minutes.