Skip to content

React Router v7 — Complete Guide

DodaTech 7 min read

In this tutorial, you'll learn about React Router v7. We cover key concepts, practical examples, and best practices.

What You'll Learn

Build modern React applications with React Router v7 — using loaders and actions for data fetching, nested routes for layout persistence, error boundaries per route, and TypeScript for type-safe navigation.

Why It Matters

React Router v7 introduces a data-first paradigm where routes own their data loading and mutations. Loaders fetch data before rendering, actions handle form submissions, and error boundaries catch failures per route. This replaces the useEffect-for-data pattern with a declarative, server-renderable approach.

Real-World Use

A blog application where each route loads its own content via loaders, comments are submitted via actions, the admin section has nested layouts with persistent navigation, and 404 errors show a custom fallback without crashing the entire app.

Learning Path

flowchart LR
    A[React Basics] --> B[React Router v7]
    B --> C[API Integration]
    C --> D[State Management]
    D --> E[Testing React]
    E --> F[Final Project]
    B -->|You are here| B

Setting Up a Data Router

React Router v7 requires a data router for loaders and actions. Use createBrowserRouter to set it up.

import { createBrowserRouter, RouterProvider, Outlet, Link } from "react-router-dom";

// Layout component wraps all child routes
function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
        <Link to="/posts">Posts</Link>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

function Home() {
  return <h1>Welcome to the Blog</h1>;
}

function About() {
  return <h1>About Us</h1>;
}

const router = createBrowserRouter([
  {
    element: <Layout />,
    children: [
      { path: "/", element: <Home /> },
      { path: "/about", element: <About /> },
      { path: "/posts", lazy: () => import("./routes/Posts") },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

Expected output: Navigation links change the URL and render the corresponding component inside the <Outlet />. The nav stays persistent. The Posts route uses lazy for code splitting — it loads only when the user navigates to /posts.

Loaders — Fetching Data Before Render

Loaders fetch data before the route component renders. The component accesses the data via useLoaderData.

import {
  createBrowserRouter,
  RouterProvider,
  useLoaderData,
  Link,
  Outlet,
} from "react-router-dom";

interface Post {
  id: number;
  title: string;
  body: string;
}

async function postsLoader() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts");
  if (!res.ok) throw new Response("Failed to load posts", { status: 500 });
  return res.json() as Promise<Post[]>;
}

function Posts() {
  const posts = useLoaderData() as Post[];
  return (
    <div>
      <h1>Posts ({posts.length})</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

// Route with loader
const router = createBrowserRouter([
  {
    path: "/posts",
    element: <Posts />,
    loader: postsLoader,
  },
]);

Expected output: When navigating to /posts, the page shows "Posts (100)" with a list of titles. The data is fetched before the component renders — no loading spinner needed for the initial data. The component uses useLoaderData which is typed based on the loader's return type.

Route-Level Error Boundaries

Each route can define an errorElement to catch rendering errors, loader failures, and action errors.

import { useRouteError, isRouteErrorResponse } from "react-router-dom";

function PostErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <div style={{ padding: "24px" }}>
        <h1>{error.status}</h1>
        <p>{error.statusText}</p>
        {error.data?.message && <p>{error.data.message}</p>}
      </div>
    );
  }

  return (
    <div style={{ padding: "24px" }}>
      <h1>Unexpected Error</h1>
      <p>{(error as Error).message}</p>
    </div>
  );
}

// Route config
const router = createBrowserRouter([
  {
    path: "/posts/:id",
    loader: postLoader,
    element: <PostDetail />,
    errorElement: <PostErrorBoundary />,
  },
]);

Expected output: If the post loader fails (e.g., 404 from the API), the error boundary displays a styled error page with the status code and message instead of a white screen. Network errors show "Unexpected Error" with the error message.

Actions and Form Submissions

Actions handle form submissions on the server. Use Form component for progressive enhancement.

import {
  Form,
  useActionData,
  useNavigation,
  redirect,
} from "react-router-dom";

interface ActionData {
  errors?: { title?: string; body?: string };
}

async function createPostAction({ request }: { request: Request }) {
  const formData = await request.formData();
  const title = formData.get("title") as string;
  const body = formData.get("body") as string;

  const errors: ActionData["errors"] = {};
  if (!title) errors.title = "Title is required";
  if (!body) errors.body = "Body is required";

  if (Object.keys(errors).length > 0) {
    return { errors } as ActionData;
  }

  await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    body: JSON.stringify({ title, body, userId: 1 }),
    headers: { "Content-Type": "application/json" },
  });

  return redirect("/posts");
}

function CreatePost() {
  const actionData = useActionData() as ActionData | undefined;
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" type="text" />
        {actionData?.errors?.title && (
          <span role="alert">{actionData.errors.title}</span>
        )}
      </div>

      <div>
        <label htmlFor="body">Content</label>
        <textarea id="body" name="body" rows={6} />
        {actionData?.errors?.body && (
          <span role="alert">{actionData.errors.body}</span>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating..." : "Create Post"}
      </button>
    </Form>
  );
}

Expected output: Submitting an empty form shows validation errors "Title is required" and "Body is required". Filling valid data and submitting shows "Creating..." on the button, then redirects to /posts after the API call completes. The Form component works even without JavaScript enabled.

Nested Routes with Outlet

Nested routes create persistent UI patterns. The parent layout stays mounted while child routes swap.

import { Outlet, NavLink } from "react-router-dom";

interface DashboardLayoutProps {
  sidebarItems: { to: string; label: string }[];
}

function DashboardLayout({ sidebarItems }: DashboardLayoutProps) {
  return (
    <div style={{ display: "flex" }}>
      <nav style={{ width: 240, borderRight: "1px solid #ccc" }}>
        {sidebarItems.map((item) => (
          <NavLink
            key={item.to}
            to={item.to}
            style={({ isActive }) => ({
              display: "block",
              padding: "8px 16px",
              background: isActive ? "#007bff" : "transparent",
              color: isActive ? "#fff" : "#000",
              textDecoration: "none",
            })}
          >
            {item.label}
          </NavLink>
        ))}
      </nav>
      <main style={{ flex: 1, padding: "24px" }}>
        <Outlet />
      </main>
    </div>
  );
}

// Route config uses Outlet for nested children
const router = createBrowserRouter([
  {
    path: "/dashboard",
    element: <DashboardLayout sidebarItems={[
      { to: "/dashboard/overview", label: "Overview" },
      { to: "/dashboard/analytics", label: "Analytics" },
      { to: "/dashboard/settings", label: "Settings" },
    ]} />,
    children: [
      { path: "overview", element: <Overview /> },
      { path: "analytics", element: <Analytics /> },
      { path: "settings", element: <Settings /> },
    ],
  },
]);

Expected output: The dashboard sidebar is always visible. Clicking Overview, Analytics, or Settings updates the right panel without re-rendering the sidebar. The active link is highlighted in blue. The URL updates to match.

URL Search Params

Read and update URL search params for filter, sort, and pagination state.

import { useSearchParams } from "react-router-dom";

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  const category = searchParams.get("category") || "all";
  const sort = searchParams.get("sort") || "name";
  const page = Number(searchParams.get("page")) || 1;

  function updateFilter(key: string, value: string) {
    setSearchParams((prev) => {
      if (value) {
        prev.set(key, value);
      } else {
        prev.delete(key);
      }
      return prev;
    });
  }

  return (
    <div>
      <select
        value={category}
        onChange={(e) => updateFilter("category", e.target.value)}
      >
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>

      <select
        value={sort}
        onChange={(e) => updateFilter("sort", e.target.value)}
      >
        <option value="name">Name</option>
        <option value="price">Price</option>
      </select>

      <p>Page {page}</p>
      <button onClick={() => updateFilter("page", String(page + 1))}>
        Next Page
      </button>
    </div>
  );
}

Expected output: Selecting a category updates the URL to ?category=electronics. Changing sort adds &sort=price. Clicking next page adds &page=2. The URL can be bookmarked and shared — the filter state is preserved.

Common Mistakes

1. Not Using <Form> for Mutations

Using regular <form> with onSubmit and fetch gives up progressive enhancement. The <Form> component works even if JavaScript fails to load.

2. Throwing Errors Instead of Returning Response

In loaders, throw new Response("message", { status: 404 }) to trigger the nearest error element. Throwing an Error object shows a generic error page.

3. Forgetting to Handle Loading States

Use useNavigation().state to check if a navigation or form submission is in progress. Disable buttons and show loading indicators during transitions.

Practice Questions

What is the difference between useLoaderData and useEffect for data fetching?

useLoaderData fetches data before the component renders during the navigation lifecycle. useEffect fetches data after the component mounts, causing a flash of empty or loading content. Loaders also work during server-side rendering and progressive enhancement.

How do I protect routes that require authentication?

Create a loader that checks the auth status and redirects to /login if unauthenticated. Use the loader on protected routes. Optionally, create a parent layout route with the auth check that wraps all protected children.

What is the purpose of the action function in React Router v7?

The action function handles form submissions and other mutations (POST, PUT, PATCH, DELETE). It receives the request, processes form data, performs the mutation, and either returns validation errors or redirects to another route.

Challenge

Build a blog application with: a posts list route with a loader, a post detail route with a loader and error boundary, a create post form with an action and validation, and nested dashboard routes. Add a loading indicator using useNavigation().state.

Real-World Task

Add React Router v7 to an existing React app. Replace all useEffect data fetching patterns with loaders. Convert form submissions to <Form> with actions. Add error boundaries to every route group. Add a search params-based filter to a listing page.


This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production applications where React Router v7 powers seamless navigation and data loading for thousands of users.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro