React Router Cheatsheet
Migration and Pitfalls
Use this React Router reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Pick Your Path
| Coming from | Route |
|---|---|
| Remix v2 | Rename packages to v7, then v7 to v8 |
| React Router v6 | Move to v7 behind future flags, then v7 to v8 |
| v7 library mode | Adopt v8 first, then layer Framework mode on top |
| v7 Framework mode | Turn on the v8_* flags, then bump the major |
Two rules make every one of these boring. Do one hop at a time, and turn on every future flag on the version you already run before bumping the major. The flag system exists so the major itself ships no new behavior.
v7 to v8
All future.v8_* flags were removed and their behavior is the default.
Adopt them one at a time on v7.x, then upgrade.
| v7 flag | v8 behavior |
|---|---|
v8_middleware | Middleware always on, context is a RouterContextProvider |
v8_splitRouteModules | Top level splitRouteModules, default true |
v8_viteEnvironmentApi | Always on, Vite 7 is the baseline |
v8_passThroughRequests | Raw request always passed through, plus a url arg |
v8_trailingSlashAwareDataRequests | Trailing slash aware data URLs |
Other breaking changes in v8.0.0:
- react-router-dom is gone. Import from react-router, except
RouterProvider and HydratedRouter which come from react-router/dom.
- meta data removed. Use loaderData on MetaArgs, MetaMatch,
UIMatch, and Route.ComponentProps.matches.
- AppLoadContext removed. A custom server's getLoadContext must
return a RouterContextProvider, not a plain object.
- Cloudflare dev proxy removed. Use @cloudflare/vite-plugin.
- hasErrorBoundary removed from RouteObject, <Route> props, and
lazy definitions. The router infers it.
- Baseline moved. Node 22.22.0+, React 19.2.7+, Vite 7+, ESM only.
npm i react-router@8 npm i -D @react-router/dev@8 npx react-router typegen && npx tsc # find the type fallout first
v6 to v7
Mostly mechanical. The package rename is a find and replace.
# GNU sed, Linux find ./src \( -name "*.ts" -o -name "*.tsx" \) -type f \ -exec sed -i 's|from "react-router-dom"|from "react-router"|g' {} + # BSD sed, macOS, note the empty -i argument find ./src \( -name "*.ts" -o -name "*.tsx" \) -type f \ -exec sed -i '' 's|from "react-router-dom"|from "react-router"|g' {} +
Turn on all of these on v6.x first, fix what breaks, then bump.
| v6 future flag | What it changes |
|---|---|
v7_relativeSplatPath | Relative paths inside a splat route resolve differently |
v7_startTransition | State updates wrapped in React.startTransition |
v7_fetcherPersist | Fetcher lifetime keyed to idle, not to unmount |
v7_normalizeFormMethod | formMethod is uppercase, POST not post |
v7_partialHydration | Partial hydration of a data router |
v7_skipActionErrorRevalidation | No revalidation after a 4xx or 5xx action |
| v6 idiom | v7 and later |
|---|---|
react-router-dom | react-router |
json({ ... }) | Return a plain object, or data(value, init) |
defer({ ... }) | Return bare promises in a plain object |
RouterProvider from react-router-dom | From react-router/dom |
Remix v2 to v7
Remix v2 and v7 Framework mode are the same runtime, so this is a rename plus a config move.
| Remix v2 | React Router |
|---|---|
@remix-run/react | react-router |
@remix-run/server-runtime | react-router |
@remix-run/node | @react-router/node |
@remix-run/dev | @react-router/dev |
@remix-run/express | @react-router/express |
@remix-run/serve | @react-router/serve |
@remix-run/cloudflare | @react-router/cloudflare |
@remix-run/fs-routes | @react-router/fs-routes |
@remix-run/route-config | @react-router/dev |
Then swap the Vite plugin from vitePlugin as remix to reactRouter, add
app/routes.ts and react-router.config.ts, and rename the entry file
components (RemixServer becomes ServerRouter, RemixBrowser becomes
HydratedRouter).
Library Mode to Framework Mode
Both starting points converge on the same result. This one needs judgment, not a codemod, so migrate route by route.
1. Install @react-router/dev and add reactRouter() to
vite.config.ts.
2. Add react-router.config.ts with ssr: false so nothing has to move
to the server on day one.
3. Add app/root.tsx with the Layout export, <Meta />, <Links />,
<Scripts />, and <ScrollRestoration />.
4. Add app/routes.ts. From createBrowserRouter, translate the route
objects. From <Routes> and <Route>, translate the JSX tree.
5. Delete the old createRoot call. The plugin owns the entry now.
6. Convert routes one at a time: element becomes the default export,
loader and action become exports, errorElement becomes
ErrorBoundary, hydrateFallbackElement becomes HydrateFallback.
7. Flip ssr: true when you are ready, and move each clientLoader that
can run on the server to a loader.
Step 7 is the judgment call. Anything touching window, localStorage,
or a browser only SDK stays in a clientLoader or moves into a
useEffect, or SSR crashes on the first render.
Pitfalls
| Symptom | Cause and fix |
|---|---|
react-router-dom not found | v8 removed it, import from react-router |
| Nested route renders nothing | Parent forgot <Outlet /> |
| Error page never hydrates | <Scripts /> is not in the Layout export |
Every NavLink looks active | Add end to the / link |
useNavigate in an effect to redirect | Throw redirect() from the loader |
Fetching in useEffect per component | Move it to a loader, kill the waterfall |
Params are "42" not 42 | Params are always strings, parse and validate |
| Filter input floods history | setSearchParams(next, { replace: true }) |
| 404 page returns HTTP 200 | throw data("Not Found", { status: 404 }) |
| Basename shows up twice | Leave it out of to, the router adds it |
| Secret leaked into the client bundle | Move the module to *.server.ts |
meta reads undefined | v8 renamed data to loaderData |
| Custom server context errors | getLoadContext must return a RouterContextProvider |