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

typescript dev.to

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

Source: dev.to

arrow_back Back to Tutorials