React Router v7 — Complete Guide
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
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