Running React Libraries in Preact Using preact/compat
Use preact/compat to run React libraries in Preact, with alias configs for Vite, webpack, Rollup, Jest, TypeScript, and common breakages.
preact/compat is a compatibility layer, shipped inside the main preact package since Preact X, that maps React’s public API onto Preact so most React libraries run unmodified while your app ships around 9.5KB of framework instead of React’s much larger runtime.
Swapping the alias in is a five-minute job. Finding out three days later that one date picker is throwing errors from somewhere deep inside node_modules is the part nobody warns you about. You enable it by aliasing react and react-dom to preact/compat in your bundler: no code changes to your components, no separate package to install. This guide gives you the exact alias config for every major toolchain, a migration walkthrough with the bundle-size payoff, and an honest account of which libraries break.
Key Takeaways
preact/compatships inside thepreactpackage. Compat now lives in core, so the standalonepreact-compatpackage is obsolete andnpm install preactis all you need.- The entire mechanism is aliasing four import paths:
react,react-dom,react-dom/test-utils, andreact/jsx-runtimeall point at Preact. - With
@preact/preset-vite, aliasing is automatic, so you do not hand-writeresolve.alias. - In webpack the
react-domalias must be listed belowreact-dom/test-utils, or the broader rule shadows the test-utils mapping. - Compat covers React’s public API, not its internals. Libraries reaching into deep
react-dominternal paths, or depending on the newest React 19 APIs, can still break.
What is preact/compat, and why does it exist?
preact/compat translates React’s public API surface (React.Component, hooks, createPortal, forwardRef, memo, the JSX runtime) into Preact equivalents so third-party components written against React resolve to Preact at build time. Preact’s own npm listing sells the library on wide React support behind a single alias, and that compatibility is what lets you reuse the React ecosystem without a rewrite.
There is no preact-compat package to install anymore. The official upgrade guide explains that the layer once shipped on its own and was folded into the core repository to keep coordination simpler, so anyone moving up has to swap old preact-compat imports and aliases for preact/compat. The unscoped package is a dead end: its GitHub repository has been archived and read-only since December 2021, and its npm page tells you to uninstall it, since Preact X carries compat by default. Preact 10.x is the current stable line, with 11.0.0 at release-candidate stage rather than general availability; the Preact releases page lists the exact version numbers.
Aliasing Is the Whole Trick
Discover how at OpenReplay.com.
The entire mechanism is aliasing: you point react, react-dom, react-dom/test-utils, and react/jsx-runtime at Preact so every existing import, including third-party libraries deep in node_modules, resolves to preact/compat instead of React. Nothing in your component code changes. An import { useState } from 'react' statement stays exactly as written; the bundler rewrites where react resolves.
The four canonical entries, from Preact’s guide to aliasing React to Preact:
| Import path | Alias target | Why |
|---|---|---|
react | preact/compat | Core React API |
react-dom/test-utils | preact/test-utils | Test utilities |
react-dom | preact/compat | DOM renderer (must sit below test-utils) |
react/jsx-runtime | preact/jsx-runtime | Automatic JSX transform |
Aliasing only react and react-dom, a common shortcut in older tutorials, leaves libraries that import the JSX runtime or react-dom/test-utils resolving to React, which reintroduces the bytes you were trying to drop.
Per-Toolchain Alias Config
Vite (recommended default)
With @preact/preset-vite, aliasing is automatic and you should not hand-write resolve.alias. The preset switches the React aliases on for you: the reactAliasesEnabled option governs them and is set to true unless you turn it off. It also configures the JSX transform for you.
// vite.config.ts
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({
plugins: [preact()], // JSX + react→preact/compat aliasing handled automatically
});
If you run Vite without the preset, add the manual fallback:
export default defineConfig({
resolve: {
alias: {
react: 'preact/compat',
'react-dom/test-utils': 'preact/test-utils',
'react-dom': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
});
Webpack
In webpack the react-dom alias must be listed below react-dom/test-utils, otherwise the broader react-dom rule shadows the test-utils mapping and the test utilities silently resolve to the wrong module.
const config = {
resolve: {
alias: {
react: 'preact/compat',
'react-dom/test-utils': 'preact/test-utils',
'react-dom': 'preact/compat', // Must be below test-utils
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
};
Rollup
Install @rollup/plugin-alias and register it before @rollup/plugin-node-resolve, so the rewrites happen before Rollup resolves modules.
import alias from '@rollup/plugin-alias';
export default {
plugins: [
alias({
entries: [
{ find: 'react', replacement: 'preact/compat' },
{ find: 'react-dom/test-utils', replacement: 'preact/test-utils' },
{ find: 'react-dom', replacement: 'preact/compat' },
{ find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' },
],
}),
],
};
Node / Next.js (no bundler alias)
Node runtimes ignore bundler aliases, Next.js included, so the alias goes in package.json instead, using the published @preact/compat package. That scoped package exists only so npm’s built-in aliasing has something to point at; its whole job is to re-export preact/compat unchanged. Note the scoped @preact/compat is not the dead unscoped preact-compat.
{
"dependencies": {
"react": "npm:@preact/compat",
"react-dom": "npm:@preact/compat"
}
}
Jest
Jest rewrites module paths with regex entries under moduleNameMapper:
{
"moduleNameMapper": {
"^react$": "preact/compat",
"^react-dom/test-utils$": "preact/test-utils",
"^react-dom$": "preact/compat",
"^react/jsx-runtime$": "preact/jsx-runtime"
}
}
TypeScript
TypeScript resolves types independently of your bundler, so map the paths in tsconfig.json and enable skipLibCheck. Turn skipLibCheck on because a handful of React libraries lean on types that compat does not ship, and a full pass over every .d.ts in node_modules will fail on those declarations.
{
"compilerOptions": {
"skipLibCheck": true,
"baseUrl": "./",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
"react-dom": ["./node_modules/preact/compat/"],
"react-dom/*": ["./node_modules/preact/compat/*"]
}
}
}
A Minimal Migration Walkthrough
Migrating an existing React app to Preact on modern tooling is a four-step operation:
- Swap dependencies. Remove
react,react-dom, and their@types, since Preact ships its own TypeScript types, thennpm install preactandnpm install -D @preact/preset-vite. - Add the preset. Put
preact()in your Vite plugins. It handles both the JSX transform and thereact → preact/compatalias, so you delete any manualesbuild.jsxInject/jsxFactoryconfig from older Vite 2-era setups. - Change the render entry point. Swap React DOM’s mount call for Preact’s
render:
// Before
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// After
import { render } from 'preact';
render(<App />, document.getElementById('root'));
- Build and check size. The payoff is the point: Preact core plus
preact/compatlands at roughly 9.5KB min+gzip, against something closer to 60KB for React 19 plus React DOM, nearly all of it in thereact-dom/cliententry. For an app whose components already resolve through compat, that difference is close to free.
When does preact/compat break?
Compat covers React’s public API, not its internals. Libraries that reach into private react-dom internal paths, or that lean on some of the newer React 19 APIs, can break even when the alias is correct, so verify each dependency before shipping. Type mismatches from React-typed libraries are expected and handled by skipLibCheck; runtime failures are the ones to watch, and session replays of these integrations frequently surface them as console errors thrown from inside a dependency rather than your own code.
A quick pre-flight check before committing a library:
- Grep the package for deep
react-dom/internal imports, the most common breakage signal. - Check for React 19-only APIs the library depends on; verify coverage against the current Preact release rather than assuming.
- Run the library’s own test suite under the Jest
moduleNameMapperabove to catch failures early. - Smoke-test in dev, watching the console for errors originating inside the dependency.
SSR and framework users hit a separate class of issue: because bundler aliases don’t apply in Node, Next.js and similar runtimes need the package.json alias, and Vite’s ssrLoadModule path can bypass some alias config, so confirm both client and server resolve to preact/compat.
Alias the four entries for your toolchain, run your dependencies’ tests through the same mapping, and you can reuse most of the React ecosystem at a fraction of the bytes. The honest exceptions are libraries coupled to React’s internals rather than its public API. Start by adding @preact/preset-vite to a branch and measuring your production bundle before and after.
FAQs
What is the difference between @preact/compat and the old preact-compat package?
The scoped @preact/compat is a live npm package that re-exports preact/compat, used only to alias react through package.json in Node runtimes like Next.js where bundler aliases do not apply. The unscoped preact-compat is a different, archived package whose repository has been read-only since December 2021; it targeted Preact 8.x, and Preact X now ships compat inside core. Never install the unscoped one.
Do I still need to write resolve.alias manually if I use @preact/preset-vite?
No. With @preact/preset-vite, aliasing react and react-dom to preact/compat happens for you, governed by the reactAliasesEnabled option, which is on unless you turn it off. Adding preact() to your Vite plugins handles both the JSX transform and the aliasing, so hand-writing resolve.alias is redundant and can conflict. You only write the manual four-entry alias block when running Vite without the preset.
Why do my test utilities resolve to the wrong module after aliasing in webpack?
Because the react-dom alias is listed above react-dom/test-utils in your webpack config. Webpack matches the broader react-dom rule first, so it shadows the more specific test-utils mapping and the test utilities silently resolve to preact/compat instead of preact/test-utils. Fix it by placing the react-dom entry below react-dom/test-utils. Rollup has a related ordering rule: put @rollup/plugin-alias before @rollup/plugin-node-resolve.
Why does a React library break even though my preact/compat alias is correct?
Because compat maps React's public API, not its internals. Libraries that import deep react-dom internal paths, or depend on the newest React 19 APIs, can fail at runtime even with a correct alias. These surface as console errors thrown from inside the dependency, not from your own code. Before committing a library, grep it for deep react-dom/ imports, and run its own test suite under the Jest moduleNameMapper.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k