Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

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, serv

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
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
// react-router.config.ts
import type { Config } from '@react-router/dev/config'
export default { ssr: true } satisfies Config

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

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

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>
  )
}

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 }
}

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>
  )
}

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 })
  }
}
<Form method="post">
  <button name="intent" value="publish">Publish</button>
  <button name="intent" value="delete">Delete</button>
</Form>

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>
  )
}

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>
  )
}

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
// react-router.config.ts — Vercel
import { vercelPreset } from '@vercel/react-router/vite'
export default { ssr: true, presets: [vercelPreset()] } satisfies Config

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)

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} /> }

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

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 }

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>>
}

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

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.