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 fromRoute
Remix v2Rename packages to v7, then v7 to v8
React Router v6Move to v7 behind future flags, then v7 to v8
v7 library modeAdopt v8 first, then layer Framework mode on top
v7 Framework modeTurn 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 flagv8 behavior
v8_middlewareMiddleware always on, context is a RouterContextProvider
v8_splitRouteModulesTop level splitRouteModules, default true
v8_viteEnvironmentApiAlways on, Vite 7 is the baseline
v8_passThroughRequestsRaw request always passed through, plus a url arg
v8_trailingSlashAwareDataRequestsTrailing 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 flagWhat it changes
v7_relativeSplatPathRelative paths inside a splat route resolve differently
v7_startTransitionState updates wrapped in React.startTransition
v7_fetcherPersistFetcher lifetime keyed to idle, not to unmount
v7_normalizeFormMethodformMethod is uppercase, POST not post
v7_partialHydrationPartial hydration of a data router
v7_skipActionErrorRevalidationNo revalidation after a 4xx or 5xx action
v6 idiomv7 and later
react-router-domreact-router
json({ ... })Return a plain object, or data(value, init)
defer({ ... })Return bare promises in a plain object
RouterProvider from react-router-domFrom 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 v2React Router
@remix-run/reactreact-router
@remix-run/server-runtimereact-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

SymptomCause and fix
react-router-dom not foundv8 removed it, import from react-router
Nested route renders nothingParent forgot <Outlet />
Error page never hydrates<Scripts /> is not in the Layout export
Every NavLink looks activeAdd end to the / link
useNavigate in an effect to redirectThrow redirect() from the loader
Fetching in useEffect per componentMove it to a loader, kill the waterfall
Params are "42" not 42Params are always strings, parse and validate
Filter input floods historysetSearchParams(next, { replace: true })
404 page returns HTTP 200throw data("Not Found", { status: 404 })
Basename shows up twiceLeave it out of to, the router adds it
Secret leaked into the client bundleMove the module to *.server.ts
meta reads undefinedv8 renamed data to loaderData
Custom server context errorsgetLoadContext must return a RouterContextProvider