DH
13 min read

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.

reactnodejstypescript

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

npx create-react-router@latest my-app
cd my-app
npm install
npm run dev

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

npm install react-router @react-router/dev

Then add the plugin to your vite.config.ts:

import { defineConfig } from 'vite';
import { reactRouter } from '@react-router/dev/vite';

export default defineConfig({
plugins: [reactRouter()],
});

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:

import { type RouteConfig, route, index } from '@react-router/dev/routes';

export default [
index('./routes/home.tsx'),
route('dashboard', './routes/dashboard.tsx', [
index('./routes/dashboard/overview.tsx'),
route(':projectId', './routes/dashboard/project.tsx'),
]),
] satisfies RouteConfig;

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:

import { flatRoutes } from '@react-router/fs-routes';

export default flatRoutes() satisfies RouteConfig;

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:

// Discover this route lazily (don't prefetch until clicked)
<Link to="/heavy-page" discover="none">Reports</Link>

// Default: discover the route module when the link renders
<Link to="/dashboard">Dashboard</Link>

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:

route('users/:userId/posts/:postId', './routes/post.tsx')

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.

// routes/dashboard/project.tsx

export async function loader({ params }: Route.LoaderArgs) {
const project = await fetchProject(params.projectId);
if (!project) throw new Response('Not Found', { status: 404 });
return project;
}

export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
await updateProject(params.projectId, Object.fromEntries(formData));
return redirect(`/dashboard/${params.projectId}`);
}

export default function ProjectPage({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.name}</div>;
}

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:

export async function clientLoader({ serverLoader, params }: Route.ClientLoaderArgs) {
const cached = cache.get(params.projectId);
if (cached) return cached;
const data = await serverLoader();
cache.set(params.projectId, data);
return data;
}
clientLoader.hydrate = true; // run on initial page load, not just transitions

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.

// routes/products/product.tsx
import type { Route } from './+types/product';

export function loader({ params }: Route.LoaderArgs) {
// 👆 params is { id: string } — derived from "products/:id"
return { name: `Product #${params.id}` };
}

export default function Product({ loaderData }: Route.ComponentProps) {
// 👆 { name: string } — inferred from loader return
return <h1>{loaderData.name}</h1>;
}

The following types are generated per route:

TypePurpose
Route.LoaderArgsArgs to the server loader, including typed params
Route.ClientLoaderArgsArgs to the client clientLoader
Route.ActionArgsArgs to the server action
Route.ClientActionArgsArgs to the client clientAction
Route.ComponentPropsProps to the default export, including typed loaderData
Route.ErrorBoundaryPropsProps to the route's ErrorBoundary
Route.HydrateFallbackPropsProps to the HydrateFallback component

You can regenerate types on demand with:

react-router typegen
# or watch mode:
react-router typegen --watch

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:

// react-router.config.ts
export default {
async prerender() {
return ['/', '/about', '/pricing'];
},
};

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-dom is replaced by react-router — update your import paths
  • Several deprecated APIs from v5 compatibility layers are removed
  • If you were using createBrowserRouter with 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 with react-router
  • Rename your remix.config.js to use the Vite plugin config (vite.config.ts with reactRouter() plugin)
  • routes.ts replaces the app/routes/ filename convention, though flatRoutes() from @react-router/fs-routes lets you keep the filesystem pattern if you prefer
  • The entry.server.tsx and entry.client.tsx files remain, with minor API adjustments
  • Environment-specific adapters (e.g. @remix-run/node) are replaced by react-router itself 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:

export async function loader({ request }: Route.LoaderArgs) {
const user = await getUser(request);
if (!user) throw redirect('/login');
return { user };
}

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:

// routes.ts
route('app', './routes/app-layout.tsx', [
index('./routes/app/dashboard.tsx'),
route('settings', './routes/app/settings.tsx'),
route('projects/:id', './routes/app/project.tsx'),
]),
route('login', './routes/login.tsx'),
// routes/app-layout.tsx
export async function loader({ request }: Route.LoaderArgs) {
const user = await requireUser(request); // throws redirect('/login') if not authed
return { user };
}

export default function AppLayout({ loaderData }: Route.ComponentProps) {
return (
<div>
<Nav user={loaderData.user} />
<Outlet />
</div>
);
}

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:

DimensionReact Router v7Next.js
Data modelLoaders/actions, co-located with routesServer Components, fetch in components, Route Handlers
DeploymentAny Node host, Cloudflare, staticOptimised for Vercel; self-hosting is possible but more work
CachingNo framework-level caching layerAggressive caching (React cache, fetch caching) — powerful but historically unpredictable
Type safetyGenerated route types (params, loaderData)Experimental typed routes for link paths
EcosystemSmaller, but growingLarger; more third-party examples and plugins
Migration pathDirect from Remix v2No equivalent simple upgrade path from v6
Bundle splittingAutomatic per-routeAutomatic 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

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps