React Router v7 as a Framework: Migrating from Remix and Choosing It Over Next.js
Why React Router v7's three modes make it a genuine Next.js alternative. Architecture decisions from 25 years of production experience.
React Router has been a constant in my toolkit for longer than I care to admit. When the v7 release landed and folded Remix's capabilities directly into the library itself, I paid close attention — because after 25 years of watching routing libraries come and go, this one genuinely changes the calculus for full-stack React applications.
This article is for engineers already comfortable with React who want a clear-eyed view of what React Router v7 actually is, how its three modes work, and whether it belongs in your stack instead of Next.js.
What is React Router v7?
React Router is the most widely used routing library in the React ecosystem. For most of its life it has been exactly that — a client-side routing library for single-page applications. You'd wire up your SPA, drop in a BrowserRouter, define your Route components, and move on.
v7 is something different. The Remix team merged their framework back into React Router, so the library now spans the full spectrum from a lightweight client-side SPA router to a proper full-stack framework with server rendering, data loading conventions, and bundle splitting — without forcing you to adopt any of that complexity until you need it.
The short version: React Router v7 is simultaneously the successor to Remix v2 and a drop-in upgrade for anyone on React Router v6. That dual identity is both its greatest strength and the thing that confuses people most.
React Router Modes: Declarative, Data, and Framework
Understanding the three modes is essential. They are not separate packages — they are usage patterns within the same install.
Declarative Mode
Classic React Router. You use BrowserRouter, Route, and component-based JSX to define your routing tree. No server concerns, no loaders, no framework conventions. If you have an existing SPA and want type-safe routing without disruption, declarative mode is your entry point.
Data Mode
Data mode introduces the loader/action pattern without requiring a server. You get client-side data fetching tied to routes, form submission handling via actions, and hooks like useLoaderData and useActionData. The router manages your async data lifecycle so you stop doing it manually in useEffect.
Framework Mode
This is where React Router v7 becomes a genuine Next.js alternative. Framework mode adds:
- File-system or configuration-based routing via
routes.ts - Server rendering (SSR) with streaming support
- Hot Module Replacement (HMR) during development
- Automatic bundle splitting per route
- Lazy route discovery (routes are discovered as links render or are clicked, reducing initial bundle size)
- A Vite-based build pipeline baked in
- Type-safe route modules generated automatically
You opt into this by scaffolding with create-react-router or by configuring the React Router Vite plugin directly. Once in framework mode, the development experience is very close to what Remix offered, because it effectively is Remix under a different name.
Installation & Setup
New Project
This drops you directly into framework mode with a Vite config, a routes.ts file, and TypeScript preconfigured. The generated package.json references react-router as your single dependency for both routing and the framework runtime.
Adding Framework Mode to an Existing Vite Project
Then add the plugin to your vite.config.ts:
You also need a react-router.config.ts at the project root and a root entry point (app/root.tsx). The official framework adoption guide walks through each step if you are migrating an existing SPA incrementally.
For declarative or data mode without the full framework layer, the install is identical — you simply skip the Vite plugin and drive routing programmatically with createBrowserRouter or JSX routes.
Framework Mode: Full-Stack Features
Once the Vite plugin is active, you get a routes.ts file at the root of your app:
The explicit routes.ts approach is more maintainable at scale — you see the entire route tree in one file rather than hunting through a directory structure. That said, if you prefer filesystem conventions (a la Remix), helpers exist to keep that pattern:
Both styles can coexist inside the same routes.ts.
Bundle splitting happens automatically per route. Each route module is its own chunk, so users only load code for the current route. HMR during development preserves component state where possible. The discover prop on <Link> controls eager versus lazy route discovery:
Routing Basics: Nested Routes, Params, and Hooks
Nested routes remain first-class. A parent route renders an <Outlet /> component, and child routes fill that outlet. This maps naturally to layouts — a shell with a sidebar that stays mounted while the content area changes.
Route parameters use the :param syntax:
Inside the route module, useParams() returns { userId, postId } fully typed in v7 (more on how that works in the Type Safety section below).
If you need dynamic routing outside routes.ts, useRoutes is still available for programmatic route trees. BrowserRouter is still there for declarative mode. The familiar primitives remain — v7 added layers on top.
Data Loading & Mutations
Each route module can export a loader function for reads and an action function for writes.
The loader runs on the server in framework mode, or on the client in data mode. The action handles POST requests — you point an HTML <Form> component at the current route and React Router handles the submission lifecycle, including optimistic UI via useFetcher.
Client Loaders and Client Actions
For scenarios where you want to hydrate from a server loader but also have client-side cache logic, v7 supports both:
This pattern eliminates the need for a separate caching layer like React Query for many straightforward read scenarios.
ScrollRestoration is a small but useful addition. Drop it once in your root layout and the router handles scroll position correctly on back/forward navigation.
Type Safety Improvements
This is the headline improvement in v7 framework mode, and it is worth understanding precisely how it works.
React Router's Vite plugin executes your app/routes.ts file at build time (and during react-router dev) to determine your route tree. For each route, it generates a .d.ts file inside .react-router/types/ — e.g. .react-router/types/app/routes/+types/project.d.ts. With rootDirs configured in tsconfig.json, TypeScript resolves these as if they sat next to the route modules themselves.
The result: you import from ./+types/project and get fully accurate types without writing any of them by hand.
The following types are generated per route:
| Type | Purpose |
|---|---|
Route.LoaderArgs | Args to the server loader, including typed params |
Route.ClientLoaderArgs | Args to the client clientLoader |
Route.ActionArgs | Args to the server action |
Route.ClientActionArgs | Args to the client clientAction |
Route.ComponentProps | Props to the default export, including typed loaderData |
Route.ErrorBoundaryProps | Props to the route's ErrorBoundary |
Route.HydrateFallbackProps | Props to the HydrateFallback component |
You can regenerate types on demand with:
Typed Navigation
The type safety does extend to the <Link> component and useNavigate. The to prop accepts a string path or a { pathname, search, hash } object, and TypeScript will enforce the shape of what you pass. However, React Router v7 does not ship compile-time enforcement that to="/dashbaord" (typo) is an invalid route — the to prop currently accepts string. The practical safety comes from using typed params and loaderData rather than from link-path validation. This is an area where Next.js's experimental typed routes feature (which does validate path strings) is currently ahead.
Pre-rendering & Static Pages
Framework mode supports pre-rendering individual routes at build time:
Dynamic routes can also be pre-rendered by returning the full list of paths. For content-heavy pages that don't need per-request personalisation, this is a straightforward performance win. You can mix and match — some routes statically pre-rendered, others fully server-rendered, others client-only — within the same application.
Upgrading to React Router v7: From v6 or Remix v2
From React Router v6
The upgrade is largely mechanical:
react-router-domis replaced byreact-router— update your import paths- Several deprecated APIs from v5 compatibility layers are removed
- If you were using
createBrowserRouterwith loaders and actions in v6, your code is already in the right shape for data mode
The official v7 upgrade guide covers the breaking changes in detail. Run your TypeScript compiler after the upgrade; it will surface any removed API usage immediately.
From Remix v2
Remix v2 was explicitly designed as a stepping stone to React Router v7. The core mental model — loaders, actions, nested routes, file-based routing — is identical. The practical changes:
- Replace
@remix-run/*package imports withreact-router - Rename your
remix.config.jsto use the Vite plugin config (vite.config.tswithreactRouter()plugin) routes.tsreplaces theapp/routes/filename convention, thoughflatRoutes()from@react-router/fs-routeslets you keep the filesystem pattern if you prefer- The
entry.server.tsxandentry.client.tsxfiles remain, with minor API adjustments - Environment-specific adapters (e.g.
@remix-run/node) are replaced byreact-routeritself handling the server runtime
For most Remix v2 applications, this is a day's work, not a week's. The Remix team published an explicit migration guide precisely for this reason.
Route Protection: Protected and Private Routes
The idiomatic approach is to check session state in a loader and redirect before the component renders:
Because the loader runs before rendering, the protected page's component code never executes for unauthenticated users. There is no flash of unauthenticated content, no client-side useEffect race, and no wrapper component that renders children before the check completes.
Reusable Auth Layout Route
For a more reusable pattern, create a parent layout route that performs the auth check, with all protected routes nested beneath it:
Any route nested under app-layout.tsx inherits the protection automatically. Add a route, get protection for free. This is meaningfully cleaner than wrapping every page component in a <ProtectedRoute> higher-order component, which was the v6 pattern.
Deployment & Templates
The official create-react-router scaffolder includes deployment templates for several targets. Cloudflare Workers is a first-class option — the generated adapter handles edge runtime constraints reliably.
Node.js servers, Docker containers, and static hosting are all supported deployment targets. For a Dockerised setup, the build output is a standard Node server entry point — no proprietary platform required.
React Router v7 vs Next.js: Choosing a Framework
This is the real question for engineers evaluating new projects. Here is an honest comparison:
| Dimension | React Router v7 | Next.js |
|---|---|---|
| Data model | Loaders/actions, co-located with routes | Server Components, fetch in components, Route Handlers |
| Deployment | Any Node host, Cloudflare, static | Optimised for Vercel; self-hosting is possible but more work |
| Caching | No framework-level caching layer | Aggressive caching (React cache, fetch caching) — powerful but historically unpredictable |
| Type safety | Generated route types (params, loaderData) | Experimental typed routes for link paths |
| Ecosystem | Smaller, but growing | Larger; more third-party examples and plugins |
| Migration path | Direct from Remix v2 | No equivalent simple upgrade path from v6 |
| Bundle splitting | Automatic per-route | Automatic per-page (App Router) |
React Router v7 wins on simplicity of the data model. Loaders and actions are plain async functions that return data — there is no mental overhead of deciding whether to use fetch with cache: 'force-cache', server components vs client components, or whether a middleware will interfere with your caching headers.
Next.js wins on ecosystem size and is the safer choice if your team is already fluent in it. The App Router's complexity is real, but so is the tooling investment Vercel has made around it.
If you are starting a new full-stack React application and want to avoid Vercel lock-in, React Router v7 in framework mode is a serious option. If you are on Remix v2, the migration is largely a rename exercise. And if you are on React Router v6, you have a clear, incremental path to framework mode without a rewrite.
Final Thoughts
React Router v7 is the result of merging Remix into the library, producing something coherent: a routing primitive that scales from a lightweight SPA to a full-stack framework without forcing you to switch tools. The generated type safety story is meaningfully improved over v6, the data loading model is sound, and the Remix migration path is low-friction.
The framework's biggest challenge is naming confusion — three modes, a Remix lineage, and a React Router legacy all under one package. But once you understand which mode you are in and why, the API surface is clean and the mental model holds together.
That, to me, is the mark of well-designed software evolution.
Damian Hodgkiss
Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.