July 2026 · 9 min read

What React's package.json Tells You About Its Build Toolchain

React's root package.json ships zero runtime dependencies. Every single entry in that file is a build tool, a linter, or a test runner. That distinction carries the entire architecture of the project.

Most applications have a package.json that describes what they need at runtime. React's root manifest describes something different: a factory. The file at the top of the React monorepo doesn't produce a running app. It produces other packages — the ones that end up in your node_modules. Understanding that flips the way you read every dependency in it.

0
runtime dependencies
120+
devDependencies
40+
npm scripts
4
workspaces

The First Signal: "private": true

The file opens with "private": true. This is not a safety measure — it's a declaration of intent. This package will never be published to npm directly. It exists purely to wire together the monorepo. Every tool installed here serves the act of building and validating the packages that do get published.

The "workspaces": ["packages/*"] entry tells yarn to hoist all dependencies from child packages up to this root. The build scripts then orchestrate across all of them from a single entry point.

Three Compilers Running in Parallel

The most striking thing about React's toolchain is that it runs three compilers simultaneously on the same source code. Each serves a distinct purpose, and none of them could be replaced by another without losing something.

Babel — the production transformer

There are 28 Babel packages in the devDependencies. That count alone tells you Babel is not being used as a convenience — it's the primary compilation step. The specific plugins reveal the exact feature set React targets:

"@babel/plugin-transform-arrow-functions"
"@babel/plugin-transform-block-scoping"
"@babel/plugin-transform-classes"
"@babel/plugin-transform-react-jsx"
"@babel/plugin-transform-react-jsx-development"

The presence of both react-jsx and react-jsx-development transforms is meaningful. React ships two builds for most packages — a development build with extra warnings, readable variable names, and assertion messages, and a production build that strips all of that. Babel runs different plugin combinations for each target.

The plugin-transform-block-scoping entry is the one that surprises most people. React still transpiles let and const to var for its lowest-level packages to maintain compatibility with environments that predate ES6. This is not an oversight. It's a deliberate choice about the browser baseline for the scheduler and reconciler packages.

Flow — the type checker

React was one of the first large JavaScript projects to adopt Flow, Facebook's static type checker, and it still uses it today. The manifest includes:

"flow-bin": "^0.317.0"
"flow-remove-types": "^2.317.0"
"flow-typed": "^4.1.1"

Flow is version 0.317. That version number — past 300 — reflects a decade of continuous development inside Meta's internal tooling. The external open-source version has always lagged the internal one and diverged significantly in how it handles complex React patterns. This is why React's own types feel authoritative in ways that community-maintained TypeScript definitions sometimes miss — they are the upstream source of truth.

flow-remove-types is the strip step that runs after type checking passes. Rather than using Babel to strip Flow annotations (which most projects do), React uses the dedicated tool for this pass. More control, faster stripping, no risk of Babel's plugin resolution interfering with the type annotations.

TypeScript — the downstream compatibility layer

Here's the nuance that most people miss: React uses Flow internally but ships TypeScript definitions for consumers. The manifest includes both typescript: ^5.4.3 and @babel/preset-typescript. TypeScript here is not used to type the React source — it's used to validate and generate the .d.ts declaration files that ship alongside the packages. Two type systems, each handling a different side of the same codebase.

Rollup Over Webpack — and Why It Matters

React chose Rollup as its module bundler long before Rollup became fashionable for library authors. The manifest shows a mature Rollup setup:

"rollup": "^3.29.5"
"@rollup/plugin-babel"
"@rollup/plugin-commonjs"
"@rollup/plugin-node-resolve"
"@rollup/plugin-replace"
"@rollup/plugin-typescript"
"rollup-plugin-dts"
"rollup-plugin-prettier"
"rollup-plugin-strip-banner"

The @rollup/plugin-replace entry is the most important one here. It's what enables React to produce different builds from the same source. The plugin replaces __DEV__, __EXPERIMENTAL__, process.env.NODE_ENV, and other constants at bundle time. The development build keeps the __DEV__ checks. The production build eliminates them as dead code. The same source file ships as both.

rollup-plugin-strip-banner removes the Meta copyright headers from internal files before the public packages are generated. The published react package you install has clean headers — they're applied fresh during the publish step, not carried over from the monorepo source files.

The build command tells the whole story: "build": "node ./scripts/rollup/build-all-release-channels.js" — a single Node script drives the entire pipeline across all packages and all release channels (stable, experimental, www-modern, www-classic). Rollup is invoked programmatically, not via CLI.

Google Closure Compiler — the Final Minifier

Most projects stop at Rollup + Terser. React adds a third pass:

"google-closure-compiler": "^20230206.0.0"

Google Closure Compiler is used for the production UMD builds of React and ReactDOM — the ones that ship in CDN bundles. Closure's advanced mode performs whole-program optimisation: it inlines small functions, renames variables to single characters, eliminates dead code across module boundaries, and can reduce bundle sizes beyond what any other tool achieves. The tradeoff is compilation time. Running Closure adds minutes to the build. For a library shipping to millions of projects, those bytes justify the wait.

The Testing Stack

React tests through a custom Jest runner, not stock Jest CLI:

"jest": "^29.4.2"
"jest-environment-jsdom": "^29.4.2"
"jest-snapshot-serializer-raw": "^1.2.0"

The jest config in the manifest is telling:

"jest": {
  "testRegex": "/scripts/jest/dont-run-jest-directly\\.js$"
}

This is a deliberate block. If you run jest directly from the root, it matches a single file whose entire purpose is to print an error message telling you to use the custom wrapper instead. The test script routes through scripts/jest/jest-cli.js, which adds release channel flags (--release-channel=stable, --release-channel=experimental) and environment variables that gate which features are enabled during the test run. The same test file can behave differently depending on which channel is active.

Hermes — the Mobile Parser

Three Hermes-related packages appear in the manifest:

"hermes-eslint": "^0.36.1"
"hermes-parser": "^0.36.1"
"babel-plugin-syntax-hermes-parser": "^0.36.1"

Hermes is Meta's JavaScript engine for React Native on iOS and Android. These packages ensure React's source code is parseable by Hermes's parser — not just V8's. Some JavaScript syntax that V8 accepts can cause parsing failures or performance degradation on Hermes. Running Hermes's parser as an ESLint rule catches those incompatibilities at lint time rather than at runtime on a user's device.

What This Architecture Means for Your Project

If you maintain a library — even a small one — React's toolchain has three practices worth borrowing directly:

Separate your release channels from your build targets. React distinguishes between what it builds (CJS, ESM, UMD) and which features are enabled (stable, experimental). The --release-channel flag is threaded through the entire pipeline — build, test, and publish — as a single parameter. Most library projects conflate these two axes and end up with complex conditional logic scattered across config files.

Use @rollup/plugin-replace for build-time constants. Shipping a debug flag that costs nothing in production is free. All it takes is a replace pass at bundle time. The React team has been doing this since 2016. The pattern works at any scale.

Lock your test runner to your build pipeline. The blocked direct-jest pattern is extreme, but the principle is sound. Tests that run against the wrong build artifact give you false confidence. Wrapping Jest in a script that configures the environment before running lets you make this guarantee explicit.

The full manifest is at https://raw.githubusercontent.com/react/react/refs/heads/main/package.json — paste it into any JSON tree viewer to explore the scripts section and search across all 120+ dependencies. Open it here →