How to Fix CRA Babel 'Experimental Decorators' Error Without Ejecting
Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 10–20 mins
TL;DR
- What broke: CRA's internal Babel config does not support
@decoratorsyntax out of the box; your build dies with a parser error before a single component renders. - How to fix it: Install
@babel/plugin-proposal-decoratorsand@babel/plugin-proposal-class-properties, wire them in the correct order viacustomize-craorcraco, and setexperimentalDecorators: trueintsconfig.json. - Fastest path: Use our Client-Side Sandbox below to auto-refactor this — paste your
babel.config.js,craco.config.js, or the offending source file and get a corrected output instantly.
The Incident (What does the error mean?)
You hit this wall:
SyntaxError: /src/stores/UserStore.ts: Support for the experimental syntax
'decorators-legacy' isn't currently enabled (12:1):
10 | import { observable, action } from 'mobx';
11 |
> 12 | @observable
| ^
13 | export class UserStore {
Add @babel/plugin-proposal-decorators (https://git.io/vb4yO) to the
'plugins' section of your Babel config to enable transformation.
Immediate consequence: Webpack compilation halts at the first decorated symbol. Your entire dev server or production build is dead. If this slips into a CI pipeline, every downstream deploy is blocked. MobX, TypeScript reflect-metadata, Angular-style DI patterns, and typeorm entities all trigger this.
The Attack Vector / Blast Radius
CRA enforces a locked, opinionated Babel config inside react-scripts. It deliberately excludes decorator plugins because the TC39 proposal has been in flux (legacy Stage 1 vs. the new Stage 3 spec). The two decorator proposals are mutually incompatible — using the wrong one against your library (e.g., MobX 6 expects legacy mode) produces either a silent mis-transform or a hard crash.
Cascading failure chain:
- Developer adds
@observableor@Component→ local build breaks. - Team assumes it's a TypeScript config issue → wastes hours in
tsconfig.json. - Someone ejects CRA to get Babel access → you now own the entire Webpack config forever. Ejection is irreversible and turns a 3-file project into 30 files of unmaintained config debt.
- If using
typeormorclass-validatorin a monorepo with a shared tsconfig, the misconfiguration propagates to the backend build as well.
The plugin-proposal-class-properties must come after plugin-proposal-decorators in the plugins array. Reversing the order produces a different, harder-to-diagnose error about class fields.
How to Fix It (The Solution)
Option A — craco (Recommended, No Eject)
Install dependencies:
npm install -D @craco/craco @babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties
# Replace react-scripts in package.json scripts with craco
craco.config.js — diff:
+ const { addBabelPlugin, override } = require('customize-cra');
+
+ module.exports = {
+ babel: {
+ plugins: [
+ ["@babel/plugin-proposal-decorators", { "legacy": true }],
+ ["@babel/plugin-proposal-class-properties", { "loose": true }]
+ ]
+ }
+ };
package.json scripts — diff:
- "start": "react-scripts start",
- "build": "react-scripts build",
- "test": "react-scripts test",
+ "start": "craco start",
+ "build": "craco build",
+ "test": "craco test",
tsconfig.json — diff:
{
"compilerOptions": {
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
"target": "ES6",
"strict": true
}
}
Option B — customize-cra wrapper (legacy projects already using it)
- const { override } = require('customize-cra');
+ const { override, addBabelPlugin } = require('customize-cra');
module.exports = override(
+ addBabelPlugin(["@babel/plugin-proposal-decorators", { legacy: true }]),
+ addBabelPlugin(["@babel/plugin-proposal-class-properties", { loose: true }])
);
⚠️ Plugin order is load-bearing.
decoratorsmust be index 0. Swapping them producesError: @babel/plugin-proposal-decorators must be placed before @babel/plugin-proposal-class-properties.
Enterprise Best Practice — Enforce Babel Config in Monorepo via babel.config.json at root
In an Nx or Turborepo monorepo, a per-package .babelrc is silently ignored for files outside the package root. Use a root-level babel.config.json (not .babelrc) so the transform applies to all workspace packages:
- // .babelrc (per-package, ignored for cross-package imports)
- {
- "plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]]
- }
+ // babel.config.json (repo root — applies everywhere)
+ {
+ "presets": [["@babel/preset-env"], ["@babel/preset-typescript"]],
+ "plugins": [
+ ["@babel/plugin-proposal-decorators", { "legacy": true }],
+ ["@babel/plugin-proposal-class-properties", { "loose": true }]
+ ]
+ }
💡 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. Fail fast in the pipeline — lint Babel config on every PR:
# .github/workflows/ci.yml
- name: Validate Babel decorator plugin order
run: |
node -e "
const cfg = require('./babel.config.json');
const plugins = cfg.plugins.map(p => Array.isArray(p) ? p[0] : p);
const di = plugins.indexOf('@babel/plugin-proposal-decorators');
const ci = plugins.indexOf('@babel/plugin-proposal-class-properties');
if (di === -1) { console.error('MISSING decorator plugin'); process.exit(1); }
if (ci !== -1 && ci < di) { console.error('WRONG plugin order'); process.exit(1); }
console.log('Babel decorator config OK');
"
2. Enforce experimentalDecorators in tsconfig.json via a custom ESLint rule or tsc --noEmit gate:
# In CI, before build:
npx tsc --noEmit
# tsc will error on decorator usage if experimentalDecorators is absent
3. Pin Babel plugin versions in package.json with exact versions — the legacy: true vs new decorator spec behavior changed between @babel/[email protected] and 7.23. An unpinned install after a npm update can silently switch behavior:
- "@babel/plugin-proposal-decorators": "^7.21.0"
+ "@babel/plugin-proposal-decorators": "7.23.9"
4. Add a postinstall check in package.json to assert plugin presence — catches teammate installs that forget the dep:
"scripts": {
"postinstall": "node -e \"require('@babel/plugin-proposal-decorators')\""
}