React Router v7 Complete Guide (2026): Framework Mode, Loaders & Actions

React Router v7 merged Remix directly into the router package. It works as a simple client-side router (library mode) or as a full SSR framework (framework mode). This guide focuses on framework mode — file routing, server loaders, server actions, SSR adapters, and both migration paths. npx create-react-router@latest my-app cd my-app npm run dev app/ routes/ home.tsx ← / blog.tsx ← /blog (layout) blog._index.tsx ← /blog (index) blog.$slug.tsx ← /blog/:slug root.tsx react-router.config.ts vite.co
React Router v7 merged Remix directly into the router package. It works as a simple client-side router (library mode) or as a full SSR framework (framework mode). This guide focuses on framework mode — file routing, server loaders, server actions, SSR adapters, and both migration paths.
Creating a New Project
npx create-react-router@latest my-app
cd my-app
npm run dev
Enter fullscreen mode Exit fullscreen mode
app/
routes/
home.tsx ← /
blog.tsx ← /blog (layout)
blog._index.tsx ← /blog (index)
blog.$slug.tsx ← /blog/:slug
root.tsx
react-router.config.ts
vite.config.ts
Enter fullscreen mode Exit fullscreen mode
// react-router.config.ts
import type { Config } from '@react-router/dev/config'
export default { ssr: true } satisfies Config
Enter fullscreen mode Exit fullscreen mode
File Routing Conventions
home.tsx → /
blog.tsx → /blog (layout)
blog._index.tsx → /blog (index inside layout)
blog.$slug.tsx → /blog/:slug
blog.$slug.edit.tsx → /blog/:slug/edit
_auth.tsx → layout, no URL segment
_auth.login.tsx → /login (inside _auth layout)
$.tsx → catch-all / 404
Enter fullscreen mode Exit fullscreen mode
Or use explicit config in app/routes.ts:
import { type RouteConfig, route, index, layout } from '@react-router/dev/routes'
export default [
index('routes/home.tsx'),
layout('routes/_auth.tsx', [
route('login', 'routes/login.tsx'),
route('signup', 'routes/signup.tsx'),
]),
] satisfies RouteConfig
Enter fullscreen mode Exit fullscreen mode
Loaders: Server Data Fetching
// app/routes/blog.$slug.tsx
import { useLoaderData, redirect } from 'react-router'
import type { Route } from './+types/blog.$slug'
import { db } from '~/lib/db.server'
export async function loader({ params, request }: Route.LoaderArgs) {
const post = await db.posts.findUnique({
where: { slug: params.slug, published: true },
include: { author: true }
})
if (!post) throw new Response('Not Found', { status: 404 })
return { post }
}
export function meta({ data }: Route.MetaArgs) {
return [
{ title: data?.post.title },
{ name: 'description', content: data?.post.excerpt }
]
}
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>()
// post is fully typed — no manual type annotation needed
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
Enter fullscreen mode Exit fullscreen mode
params.slug is typed as string (not string | undefined) — the types are auto-generated based on your route filename. useLoaderData<typeof loader>() returns the exact shape your loader returns.
Auth in layout loaders
// app/routes/(protected)/+layout.server.ts
export async function loader({ locals, url }: Route.LoaderArgs) {
const session = await getSession(request)
if (!session.userId) {
throw redirect(`/login?next=${encodeURIComponent(url.pathname)}`)
}
return { user: session.user }
}
Enter fullscreen mode Exit fullscreen mode
All routes inside this layout automatically require auth.
Actions: Server Mutations
// app/routes/blog.$slug.edit.tsx
import { Form, useActionData, redirect } from 'react-router'
import { z } from 'zod'
import type { Route } from './+types/blog.$slug.edit'
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.coerce.boolean().default(false)
})
export async function action({ params, request }: Route.ActionArgs) {
const { user } = await requireAuth(request)
const formData = await request.formData()
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors,
values: Object.fromEntries(formData)
}
}
const post = await db.posts.update({
where: { slug: params.slug, authorId: user.id },
data: parsed.data
})
throw redirect(`/blog/${post.slug}`)
}
export default function EditPost() {
const { post } = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
return (
<Form method="post">
<input
name="title"
defaultValue={actionData?.values?.title ?? post.title}
/>
{actionData?.errors?.title && (
<p className="error">{actionData.errors.title[0]}</p>
)}
<button type="submit">Save</button>
</Form>
)
}
Enter fullscreen mode Exit fullscreen mode
Multiple actions with intent
export async function action({ params, request }: Route.ActionArgs) {
const formData = await request.formData()
switch (formData.get('intent')) {
case 'publish':
await db.posts.update({ where: { slug: params.slug }, data: { published: true } })
return { success: true }
case 'delete':
await db.posts.delete({ where: { slug: params.slug } })
throw redirect('/blog')
default:
throw new Response('Invalid intent', { status: 400 })
}
}
Enter fullscreen mode Exit fullscreen mode
<Form method="post">
<button name="intent" value="publish">Publish</button>
<button name="intent" value="delete">Delete</button>
</Form>
Enter fullscreen mode Exit fullscreen mode
useFetcher: Mutations Without Navigation
import { useFetcher } from 'react-router'
function LikeButton({ postId, initialLikes, isLiked }: Props) {
const fetcher = useFetcher()
// Optimistic UI: read in-flight form data
const optimisticLiked = fetcher.formData
? fetcher.formData.get('liked') === 'true'
: isLiked
const optimisticCount = fetcher.formData
? initialLikes + (optimisticLiked ? 1 : -1)
: initialLikes
return (
<fetcher.Form method="post" action="/api/likes">
<input type="hidden" name="postId" value={postId} />
<input type="hidden" name="liked" value={String(!optimisticLiked)} />
<button type="submit">
{optimisticLiked ? '❤️' : '🤍'} {optimisticCount}
</button>
</fetcher.Form>
)
}
Enter fullscreen mode Exit fullscreen mode
fetcher.formData gives you in-flight data for optimistic UI — no extra state management needed.
Error Boundaries
import { useRouteError, isRouteErrorResponse } from 'react-router'
export function ErrorBoundary() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
)
}
return (
<div>
<h1>Something went wrong</h1>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
)
}
Enter fullscreen mode Exit fullscreen mode
SSR Adapters
npm install @react-router/serve @vercel/react-router # Vercel
npm install @react-router/express # Node.js
npm install @react-router/cloudflare # Cloudflare Workers
Enter fullscreen mode Exit fullscreen mode
// react-router.config.ts — Vercel
import { vercelPreset } from '@vercel/react-router/vite'
export default { ssr: true, presets: [vercelPreset()] } satisfies Config
Enter fullscreen mode Exit fullscreen mode
For Node.js (Railway, Fly.io, VPS):
// server.ts
import { createRequestHandler } from '@react-router/express'
import express from 'express'
import * as build from './build/server/index.js'
const app = express()
app.use(express.static('build/client'))
app.all('*', createRequestHandler({ build }))
app.listen(3000)
Enter fullscreen mode Exit fullscreen mode
Migrating from React Router v6
// v6
import { BrowserRouter, Routes, Route } from 'react-router-dom'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/blog/:slug" element={<BlogPost />} />
</Routes>
</BrowserRouter>
)
}
// v7 library mode
import { createBrowserRouter, RouterProvider } from 'react-router'
const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/blog/:slug', element: <BlogPost /> }
])
function App() { return <RouterProvider router={router} /> }
Enter fullscreen mode Exit fullscreen mode
react-router-dom is unified into react-router in v7.
Breaking changes from v6:
-
defer()removed — use direct async/await in loaders -
json()helper removed — return plain objects -
<Await>signature changed
Migrating from Remix v2
The simplest path — Remix v2 IS React Router v7 structurally:
npm install react-router @react-router/node @react-router/serve
npm uninstall @remix-run/react @remix-run/node @remix-run/serve
Enter fullscreen mode Exit fullscreen mode
Update imports:
// Before
import { json, redirect, useLoaderData } from '@remix-run/react'
import type { LoaderFunctionArgs } from '@remix-run/node'
// After
import { redirect, useLoaderData } from 'react-router'
import type { Route } from './+types/your-route'
// Before: return json({ user })
// After: return { user }
Enter fullscreen mode Exit fullscreen mode
Most Remix v2 apps complete this migration in a few hours.
React Router v7 vs Next.js
| Feature | React Router v7 | Next.js 15 |
|---|---|---|
| Data fetching | loader functions | Server Components + fetch |
| Mutations | action functions | Server Actions |
| Caching | Explicit | Aggressive (often surprising) |
| Learning curve | Lower | Higher (RSC mental model) |
| Ecosystem | React Router community | Vercel ecosystem |
React Router v7 is the better fit if you're coming from Remix, want predictable data fetching, or prefer loaders/actions over Server Components. If you're deep in Next.js with Server Components and Vercel's platform, switching has diminishing returns.
Generated Types
React Router generates types for every route file:
// Auto-generated: app/routes/+types/blog.$slug.ts
export namespace Route {
export interface LoaderArgs {
request: Request
params: { slug: string } // typed from filename, never undefined
}
export type LoaderData = Awaited<ReturnType<typeof loader>>
}
Enter fullscreen mode Exit fullscreen mode
params.slug is always string. useLoaderData() returns your exact loader shape. No manual types — run npm run typecheck to regenerate.
Full article at stacknotice.com/blog/react-router-v7-complete-guide-2026

