Migrating from Remix to React Router v7: What Actually Changed
A practical account of upgrading an ecommerce codebase from Remix 2 to React Router v7 — what broke, what's better, and the gotchas nobody documents.
React Router v7 absorbed Remix. Officially it’s described as “Remix rebranded,” but in practice the upgrade surfaces a bunch of subtle breakages that take real time to fix. I went through this on a production ecommerce codebase (Ukrainian SMB — socks, Nova Poshta, Monobank), so here’s what actually happens when you do this migration.
What the docs say vs. what you hit
The official migration guide describes it as a mostly-mechanical rename: swap @remix-run/react imports for react-router, update your vite config, done. This is mostly true. The part they undersell is how many files have those imports and how subtle the TypeScript breakage can be.
In a 40-route app, a find-replace took 30 seconds. The hour I lost was debugging the routes that silently broke because of the second, less-documented change.
The import rename
The biggest change: two packages become one.
// Before (Remix 2)
import { json, redirect } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";
// After (React Router v7)
import { data, redirect } from "react-router";
import { useLoaderData, Form } from "react-router";
Note: json() is gone. React Router v7 uses data() instead, or you can just return a plain object — loaders can return raw objects without wrapping them.
// Before
export async function loader({ params }: LoaderFunctionArgs) {
const product = await getProductBySlug(params.slug!);
if (!product) throw new Response("Not Found", { status: 404 });
return json({ product });
}
// After — both work
export async function loader({ params }: Route.LoaderArgs) {
const product = await getProductBySlug(params.slug!);
if (!product) throw new Response("Not Found", { status: 404 });
return { product }; // plain object, no json() wrapper needed
}
The routes.ts file — the real trap
Remix used file-based routing with conventions. React Router v7 keeps file-based routing BUT it also supports (and sometimes requires) an explicit routes.ts file.
The trap: in development, your app auto-discovers routes from the file system. In production builds, it uses routes.ts only.
If you add a new route file but forget to register it in routes.ts, it works perfectly in pnpm dev and silently 404s in production. This burned me on three new routes I built.
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/_index.tsx"),
route("produkt/:slug", "routes/produkt.$slug.tsx"),
route("katalog", "routes/katalog.tsx"), // ← easy to forget
route("kategoria/:slug", "routes/kategoria.$slug.tsx"), // ← and this
route("admin/products", "routes/admin.products.tsx"), // ← and this
// ...
] satisfies RouteConfig;
After adding new routes, always grep for the filename in routes.ts:
grep -r "katalog" app/routes.ts || echo "NOT REGISTERED"
TypeScript: the Route.LoaderArgs pattern
The old Remix pattern used LoaderFunctionArgs from the package:
// Remix 2
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ params, request }: LoaderFunctionArgs) { ... }
React Router v7 generates per-route types. The correct pattern:
// React Router v7
import type { Route } from "./+types/produkt.$slug"; // auto-generated
export async function loader({ params }: Route.LoaderArgs) { ... }
The +types/ directory is generated by the framework at build time. If you’re early in setup, this folder might not exist yet — run pnpm typecheck once to generate it, then your types will work.
If you don’t want generated types (valid during migration), use explicit interfaces:
export async function loader({ params }: { params: { slug: string } }) {
// works fine, no generated types needed
}
The meta() function signature changed
Remix 2 passed data and matches to meta(). React Router v7 passes an object:
// Remix 2
export const meta: MetaFunction = ({ data }) => {
return [{ title: data.product.name }];
};
// React Router v7
export function meta({ data }: Route.MetaArgs) {
return [{ title: data?.product?.name ?? "Product" }];
}
The data is now properly typed if you’re using generated route types. Note it can be undefined if the loader throws — so guard it.
useLoaderData() — the one that silently breaks
useLoaderData() in React Router v7 infers its return type from the loader if you’re using generated types. Without generated types, it returns unknown, and TypeScript will complain everywhere you access properties.
Quick fix during migration:
// Explicit type assertion until generated types are set up
const { product } = useLoaderData() as { product: Product };
Better fix once typegen is working:
// Fully typed, no assertion needed
const { product } = useLoaderData<typeof loader>();
Cart state management — useOutletContext vs stores
One thing React Router v7 doesn’t help with: global client state. In Remix, many apps used useOutletContext to pass cart state down from root.
// root.tsx
export default function Root() {
const [cartOpen, setCartOpen] = useState(false);
return (
<Outlet context={{ cartOpen, setCartOpen }} />
);
}
// child route
const { cartOpen, setCartOpen } = useOutletContext<CartContext>();
This works but is annoying because every route needs to import and type the context. The better pattern (and what I ended up on) is a Zustand store:
// stores/cart.ts
import { create } from 'zustand';
interface CartState {
isOpen: boolean;
openCart: () => void;
closeCart: () => void;
}
export const useCartStore = create<CartState>((set) => ({
isOpen: false,
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
}));
Then any component can call useCartStore() — no prop drilling, no context typing headaches. The route file stays clean.
One gotcha: in SSR mode (which React Router v7 enables by default), Zustand stores need to be initialized carefully to avoid hydration mismatches. The useStore with a selector pattern helps:
// Safe for SSR
const openCart = useCartStore((state) => state.openCart);
Vite config changes
The Remix Vite plugin is replaced by the React Router Vite plugin:
// vite.config.ts — before
import { vitePlugin as remix } from "@remix-run/dev";
export default defineConfig({
plugins: [remix()],
});
// vite.config.ts — after
import { reactRouter } from "@react-router/dev/vite";
export default defineConfig({
plugins: [reactRouter()],
});
The new plugin generates the +types/ directory and handles routes.ts. Make sure @react-router/dev is in your devDependencies.
What’s actually better
Explicit routes.ts is a net positive. Yes, it’s one more thing to maintain, but it means your routing is fully explicit and grep-able. No more wondering “where is this route defined?”
The typegen is great when working. Route.LoaderArgs, Route.MetaArgs, Route.ComponentProps — when these work, they’re precise in a way that LoaderFunctionArgs never was.
data() vs json() is cleaner. Less boilerplate, and returning plain objects from loaders makes testing easier.
What’s worse / still rough
The typegen takes time to stabilize. Early in setup you’ll have a lot of any or assertion workarounds while the +types/ folder doesn’t exist yet.
The migration docs skip the routes.ts trap entirely. If you have a large codebase and add new routes, you will hit the “works in dev, 404 in production” bug at least once.
SSR/SPA mode switching is confusing. React Router v7 defaults to SSR. If you were using Remix with server rendering, fine. If you want an SPA build (no Node server), you need ssr: false in reactRouter() config — and some features behave differently.
Migration checklist
- Update packages:
@remix-run/react→react-router,@remix-run/node→react-router, add@react-router/dev - Update vite config:
remix()→reactRouter() - Find-replace all imports in route files
- Audit
routes.ts— make sure every route file is registered - Replace
json()with plain objects ordata() - Update
meta()function signatures - Run
pnpm typecheckto generate+types/directory - Fix any remaining TypeScript errors (usually
useLoaderDatatyping) - Test in production build (
pnpm build && pnpm start) — not justpnpm dev
Step 9 is the one most people skip, and it’s where the routes.ts trap catches you.
Conclusion
The migration is worth doing. React Router v7 is a cleaner API once you’re on it, and the unified package means less cognitive overhead. Just budget time for the routes.ts audit and TypeScript cleanup — it’s not a 30-minute job on any real codebase, and the dev/prod discrepancy makes bugs easy to miss.
The biggest lesson: always verify your routes are registered in routes.ts, and always run a production build before shipping.