
React Router V7
- 511 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
react-router-v7 is a Beagle agent skill that supplies current React Router v7 routing, loader, action, and navigation patterns for developers who need correct data-driven React apps without hunting migration guides.
About
react-router-v7 is a Beagle plugin skill from existential-birds/beagle with 435 skills.sh installs that encodes React Router v7 best practices for data-driven routing. It documents createBrowserRouter and RouterProvider setup, framework-mode routes.ts with the Vite plugin, nested routes with Outlet, dynamic and splat segments, and decision gates for Form versus useFetcher and loader versus useEffect. Triggers include createBrowserRouter, useLoaderData, useActionData, useFetcher, NavLink, and protected-route work. Four reference files—loaders.md, actions.md, navigation.md, and advanced.md—cover parallel loading, mutations, programmatic navigation, error boundaries, and lazy loading. A mode comparison table contrasts Framework, Data, and Declarative setups for SSR, type safety, and SPA control. Developers reach for react-router-v7 when migrating from React Router v6 or wiring loaders and actions in new React 19 full-stack apps.
- Provides current React Router v7 routing, loaders, actions, and data APIs
- Generates correct file-based routing and nested layout patterns
- Handles v6-to-v7 migration differences automatically
- Supplies ready-to-use component templates for common UX flows
- Works inside Cursor, Claude Code, and Windsurf sessions
React Router V7 by the numbers
- 511 all-time installs (skills.sh)
- Ranked #618 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill react-router-v7Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 511 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
How do you implement React Router v7 loaders and actions?
Get accurate, up-to-date React Router v7 patterns and component examples directly from an agent without hunting through migration guides.
Who is it for?
React developers implementing or migrating to React Router v7 data mode who want agent-guided routing decisions instead of outdated v6 examples.
Skip if: Teams on Next.js App Router, Remix-only stacks, or legacy React Router v5 declarative APIs without v7 data APIs.
When should I use this skill?
The user implements routes, loaders, actions, Form components, fetchers, navigation guards, protected routes, or URL search params with React Router v7.
What you get
React Router v7 route configs, loader and action handlers, Form or useFetcher mutations, and nested Outlet layouts following Beagle reference patterns.
- Route configuration with loaders and actions
- Form or useFetcher mutation handlers
- Nested layout components with Outlet
By the numbers
- 435 skills.sh installs listed in Beagle catalog metadata
- Four reference markdown files for loaders, actions, navigation, and advanced topics
- Three routing modes compared: Framework, Data, and Declarative
Files
React Router v7 Best Practices
Quick Reference
Router Setup (Data Mode):
import { createBrowserRouter, RouterProvider } from "react-router";
const router = createBrowserRouter([
{
path: "/",
Component: Root,
ErrorBoundary: RootErrorBoundary,
loader: rootLoader,
children: [
{ index: true, Component: Home },
{ path: "products/:productId", Component: Product, loader: productLoader },
],
},
]);
ReactDOM.createRoot(root).render(<RouterProvider router={router} />);Framework Mode (Vite plugin):
// routes.ts
import { index, route } from "@react-router/dev/routes";
export default [
index("./home.tsx"),
route("products/:pid", "./product.tsx"),
];Route Configuration
Nested Routes with Outlets
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
{ index: true, Component: DashboardHome },
{ path: "settings", Component: Settings },
],
},
]);
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Outlet /> {/* Renders child routes */}
</div>
);
}Dynamic Segments and Splats
{ path: "teams/:teamId" } // params.teamId
{ path: ":lang?/categories" } // Optional segment
{ path: "files/*" } // Splat: params["*"]Key Decision Points
Form vs Fetcher
Use `<Form>`: Creating/deleting with URL change, adding to history Use `useFetcher`: Inline updates, list operations, popovers - no URL change
Loader vs useEffect
Use loader: Data before render, server-side fetch, automatic revalidation Use useEffect: Client-only data, user-interaction dependent, subscriptions
Gates (decision sequencing)
Answer in order. Pass means the condition is true; pick the API on the same line and stop.
<Form> vs useFetcher
1. Must the URL or history stack change (bookmark/share, back returns to prior screen)?
- Pass →
<Form>/ routeaction(oruseSubmit+ navigation). Stop. - Fail → Step 2.
2. Mutation stays on the same route (inline edit, modal, list row, no address change)?
- Pass →
useFetcher(). Stop. - Fail → Re-check step 1; you may need a dedicated action route or POST to the current URL.
loader vs useEffect
1. Is data needed for correct first render (or your intended <Suspense> boundary) for this route?
- Pass →
loader(Framework:clientLoaderwhen appropriate). Stop. - Fail → Step 2.
2. Fetch only after mount from user action, timer, or subscription (not route entry)?
- Pass →
useEffect/ event handlers. Stop. - Fail → Prefer loader + revalidation over an effect that mirrors navigation.
Additional Documentation
- Data Loading: See references/loaders.md for loader patterns, parallel loading, search params
- Mutations: See references/actions.md for actions, Form, fetchers, validation
- Navigation: See references/navigation.md for Link, NavLink, programmatic nav
- Advanced: See references/advanced.md for error boundaries, protected routes, lazy loading
Mode Comparison
| Feature | Framework Mode | Data Mode | Declarative Mode |
|---|---|---|---|
| Setup | Vite plugin | createBrowserRouter | <BrowserRouter> |
| Type Safety | Auto-generated types | Manual | Manual |
| SSR Support | Built-in | Manual | Limited |
| Use Case | Full-stack apps | SPAs with control | Simple/legacy |
Actions and Mutations
Basic Action Pattern
{
path: "/projects/:id",
action: async ({ request, params }) => {
const formData = await request.formData();
const title = formData.get("title");
await updateProject(params.id, { title });
return { success: true };
},
Component: Project,
}Form Submission
function Project() {
const actionData = useActionData();
return (
<Form method="post">
<input type="text" name="title" />
<button type="submit">Save</button>
{actionData?.success && <p>Saved!</p>}
</Form>
);
}Redirect After Action
import { redirect } from "react-router";
export async function action({ request }) {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/${project.id}`);
}Form Validation
import { data } from "react-router";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email"));
const password = String(formData.get("password"));
const errors: Record<string, string> = {};
if (!email.includes("@")) {
errors.email = "Invalid email address";
}
if (password.length < 12) {
errors.password = "Password must be at least 12 characters";
}
if (Object.keys(errors).length > 0) {
return data({ errors }, { status: 400 }); // 400 prevents revalidation
}
return redirect("/dashboard");
}
export default function Signup() {
const fetcher = useFetcher();
const errors = fetcher.data?.errors;
return (
<fetcher.Form method="post">
<input type="email" name="email" />
{errors?.email && <em>{errors.email}</em>}
<input type="password" name="password" />
{errors?.password && <em>{errors.password}</em>}
<button type="submit">Sign Up</button>
</fetcher.Form>
);
}Fetchers (Non-Navigation Mutations)
Use fetchers when you DON'T want URL changes:
import { useFetcher } from "react-router";
function TodoItem({ todo }) {
const fetcher = useFetcher();
const isDeleting = fetcher.state !== "idle";
return (
<li>
<span>{todo.title}</span>
<fetcher.Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button type="submit" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</fetcher.Form>
</li>
);
}Optimistic UI with Fetchers
function Component() {
const data = useLoaderData();
const fetcher = useFetcher();
// Show optimistic state while submitting
const title = fetcher.formData?.get("title") || data.title;
return (
<div>
<h1>{title}</h1>
<fetcher.Form method="post">
<input type="text" name="title" />
{fetcher.state !== "idle" && <p>Saving...</p>}
</fetcher.Form>
</div>
);
}Fetcher for Data Loading (Combobox)
function UserSearchCombobox() {
const fetcher = useFetcher<typeof loader>();
return (
<div>
<fetcher.Form method="get" action="/search-users">
<input
type="text"
name="q"
onChange={(e) => fetcher.submit(e.currentTarget.form)}
/>
</fetcher.Form>
{fetcher.data && (
<ul style={{ opacity: fetcher.state === "idle" ? 1 : 0.25 }}>
{fetcher.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}Optimistic List Updates
function TodoList() {
const { todos } = useLoaderData();
const fetcher = useFetcher();
const displayedTodos = todos.filter(todo => {
const isDeleting = fetcher.formData?.get("id") === todo.id;
return !isDeleting;
});
return (
<ul>
{displayedTodos.map(todo => (
<li key={todo.id}>
{todo.title}
<fetcher.Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button type="submit">Delete</button>
</fetcher.Form>
</li>
))}
</ul>
);
}Advanced Patterns
Error Boundaries
Root Error Boundary (Required)
import { useRouteError, isRouteErrorResponse } from "react-router";
function RootErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</>
);
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}
createBrowserRouter([
{
path: "/",
ErrorBoundary: RootErrorBoundary,
Component: Root,
},
]);Throwing Errors in Loaders
import { data } from "react-router";
export async function loader({ params }) {
const record = await db.getRecord(params.id);
if (!record) {
throw data("Record Not Found", { status: 404 });
}
return record;
}Nested Error Boundaries
createBrowserRouter([
{
path: "/app",
ErrorBoundary: AppErrorBoundary,
children: [
{
path: "invoices/:id",
ErrorBoundary: InvoiceErrorBoundary,
Component: Invoice,
},
],
},
]);Protected Routes
Component-Based Protection
import { Navigate, useLocation } from "react-router";
function RequireAuth({ children }: { children: JSX.Element }) {
const auth = useAuth();
const location = useLocation();
if (!auth.user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// Route configuration
{
path: "/protected",
element: (
<RequireAuth>
<ProtectedPage />
</RequireAuth>
),
}
// In login handler - redirect back
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
function handleLogin() {
auth.signin(() => {
navigate(from, { replace: true });
});
}
}Middleware (Framework Mode)
import { redirect } from "react-router";
async function authMiddleware({ context, request }) {
const userId = getUserId(request);
if (!userId) {
throw redirect("/login");
}
const user = await getUserById(userId);
context.set(userContext, user);
}
createBrowserRouter([
{
path: "/dashboard",
middleware: [authMiddleware],
Component: Dashboard,
},
]);Lazy Loading / Code Splitting
Data Mode Lazy Loading
createBrowserRouter([
{
path: "/app",
lazy: async () => {
const [Component, loader] = await Promise.all([
import("./app"),
import("./app-loader"),
]);
return { Component, loader };
},
},
]);Declarative Mode Lazy Loading
import React from "react";
const About = React.lazy(() => import("./pages/About"));
<Routes>
<Route
path="about"
element={
<React.Suspense fallback={<>Loading...</>}>
<About />
</React.Suspense>
}
/>
</Routes>Common Route Patterns
Optional Segments
{ path: ":lang?/categories" } // Optional dynamic segment
{ path: "users/:userId/edit?" } // Optional static segment at endCatch-All / Splat Routes
{ path: "files/*" }
// Access splat in loader
loader: ({ params }) => {
const filePath = params["*"]; // "path/to/file.txt"
}Multiple Params
{ path: "users/:userId/posts/:postId" }
// params.userId, params.postId available in loader/componentIndex vs Path Routes
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
// Index route - renders when parent path matches exactly
{ index: true, Component: DashboardHome },
// Path route - renders at parent + path
{ path: "settings", Component: Settings },
{ path: "profile", Component: Profile },
],
},
]);Index renders at: /dashboard Settings renders at: /dashboard/settings
Data Loading Patterns
Basic Loader
{
path: "/teams/:teamId",
loader: async ({ params, request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const team = await fetchTeam(params.teamId, query);
return { team, name: team.name };
},
Component: Team,
}
function Team() {
const data = useLoaderData();
return <h1>{data.name}</h1>;
}Parallel Data Loading
Nested routes load data in parallel automatically:
createBrowserRouter([
{
path: "/",
loader: rootLoader, // Loads in parallel
children: [
{
path: "project/:id",
loader: projectLoader, // Loads in parallel with rootLoader
},
],
},
]);Search Params in Loaders
{
path: "/search",
loader: async ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const page = url.searchParams.get("page") || "1";
return { results: await search(query, parseInt(page)) };
},
}
function SearchPage() {
const { results } = useLoaderData();
return (
<Form method="get">
<input type="text" name="q" />
<button type="submit">Search</button>
</Form>
);
}useSearchParams Hook
import { useSearchParams } from "react-router";
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get("q");
return (
<input
value={query || ""}
onChange={(e) => setSearchParams({ q: e.target.value })}
/>
);
}Revalidation Control
function shouldRevalidate({ currentUrl, nextUrl, formAction }) {
return currentUrl.pathname !== nextUrl.pathname;
}
createBrowserRouter([
{
path: "/data",
shouldRevalidate,
loader: dataLoader,
},
]);Framework Mode Loaders
// product.tsx
import { Route } from "./+types/product";
export async function loader({ params }: Route.LoaderArgs) {
const product = await getProduct(params.pid);
return { product };
}
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.product.name}</div>;
}Navigation Patterns
NavLink (Active Styling)
import { NavLink } from "react-router";
<NavLink to="/messages" end>
Messages
</NavLink>
// CSS styling
a.active { color: red; }
a.pending { animation: pulse 1s infinite; }
// Callback styling
<NavLink
to="/messages"
className={({ isActive, isPending }) =>
isActive ? "active" : isPending ? "pending" : ""
}
>
Messages
</NavLink>Link (No Active Styling)
import { Link } from "react-router";
<Link to="/login">Login</Link>
<Link to={{ pathname: "/search", search: "?q=term" }}>Search</Link>Programmatic Navigation
import { useNavigate } from "react-router";
function Component() {
const navigate = useNavigate();
// Use sparingly - only for non-user-initiated navigation
useEffect(() => {
if (inactivityTimeout) {
navigate("/logout");
}
}, [inactivityTimeout]);
// Or with options
navigate("/dashboard", { replace: true });
navigate(-1); // Go back
}Redirect in Loaders
import { redirect } from "react-router";
export async function loader({ request }) {
const user = await getUser(request);
if (!user) {
return redirect("/login");
}
return { user };
}Pending UI (Navigation State)
import { useNavigation } from "react-router";
function Root() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return (
<div>
{isNavigating && <GlobalSpinner />}
<Outlet />
</div>
);
}Form Submission State
function Component() {
const navigation = useNavigation();
const isSubmitting = navigation.formAction === "/recipes/new";
return (
<Form method="post" action="/recipes/new">
<button type="submit">
{isSubmitting ? "Saving..." : "Create Recipe"}
</button>
</Form>
);
}Index Routes
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
{ index: true, Component: DashboardHome }, // Renders at /dashboard
{ path: "settings", Component: Settings }, // Renders at /dashboard/settings
],
},
]);Layout Routes (No Path)
createBrowserRouter([
{
Component: MarketingLayout, // No path, just layout wrapper
children: [
{ index: true, Component: Home },
{ path: "contact", Component: Contact },
],
},
]);Navigate Component
import { Navigate, useLocation } from "react-router";
function RequireAuth({ children }) {
const auth = useAuth();
const location = useLocation();
if (!auth.user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}Actions and Mutations
Contents
- Basic Action Pattern
- Form Submission
- Redirect After Action
- Form Validation
- Fetchers (Non-Navigation Mutations)
- Optimistic UI with Fetchers
- Fetcher for Data Loading (Combobox)
- Optimistic List Updates
---
Basic Action Pattern
{
path: "/projects/:id",
action: async ({ request, params }) => {
const formData = await request.formData();
const title = formData.get("title");
await updateProject(params.id, { title });
return { success: true };
},
Component: Project,
}Form Submission
function Project() {
const actionData = useActionData();
return (
<Form method="post">
<input type="text" name="title" />
<button type="submit">Save</button>
{actionData?.success && <p>Saved!</p>}
</Form>
);
}Redirect After Action
import { redirect } from "react-router";
export async function action({ request }) {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/${project.id}`);
}Form Validation
import { data } from "react-router";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email"));
const password = String(formData.get("password"));
const errors: Record<string, string> = {};
if (!email.includes("@")) {
errors.email = "Invalid email address";
}
if (password.length < 12) {
errors.password = "Password must be at least 12 characters";
}
if (Object.keys(errors).length > 0) {
return data({ errors }, { status: 400 }); // 400 prevents revalidation
}
return redirect("/dashboard");
}
export default function Signup() {
const fetcher = useFetcher();
const errors = fetcher.data?.errors;
return (
<fetcher.Form method="post">
<input type="email" name="email" />
{errors?.email && <em>{errors.email}</em>}
<input type="password" name="password" />
{errors?.password && <em>{errors.password}</em>}
<button type="submit">Sign Up</button>
</fetcher.Form>
);
}Fetchers (Non-Navigation Mutations)
Use fetchers when you DON'T want URL changes:
import { useFetcher } from "react-router";
function TodoItem({ todo }) {
const fetcher = useFetcher();
const isDeleting = fetcher.state !== "idle";
return (
<li>
<span>{todo.title}</span>
<fetcher.Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button type="submit" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</fetcher.Form>
</li>
);
}Optimistic UI with Fetchers
function Component() {
const data = useLoaderData();
const fetcher = useFetcher();
// Show optimistic state while submitting
const title = fetcher.formData?.get("title") || data.title;
return (
<div>
<h1>{title}</h1>
<fetcher.Form method="post">
<input type="text" name="title" />
{fetcher.state !== "idle" && <p>Saving...</p>}
</fetcher.Form>
</div>
);
}Fetcher for Data Loading (Combobox)
function UserSearchCombobox() {
const fetcher = useFetcher<typeof loader>();
return (
<div>
<fetcher.Form method="get" action="/search-users">
<input
type="text"
name="q"
onChange={(e) => fetcher.submit(e.currentTarget.form)}
/>
</fetcher.Form>
{fetcher.data && (
<ul style={{ opacity: fetcher.state === "idle" ? 1 : 0.25 }}>
{fetcher.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}Optimistic List Updates
function TodoList() {
const { todos } = useLoaderData();
const fetcher = useFetcher();
const displayedTodos = todos.filter(todo => {
const isDeleting = fetcher.formData?.get("id") === todo.id;
return !isDeleting;
});
return (
<ul>
{displayedTodos.map(todo => (
<li key={todo.id}>
{todo.title}
<fetcher.Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button type="submit">Delete</button>
</fetcher.Form>
</li>
))}
</ul>
);
}Advanced Patterns
Contents
- Error Boundaries
- Root Error Boundary (Required)
- Throwing Errors in Loaders
- Nested Error Boundaries
- Protected Routes
- Component-Based Protection
- Middleware (Framework Mode)
- Lazy Loading / Code Splitting
- Data Mode Lazy Loading
- Declarative Mode Lazy Loading
- Common Route Patterns
- Optional Segments
- Catch-All / Splat Routes
- Multiple Params
- Index vs Path Routes
---
Error Boundaries
Root Error Boundary (Required)
import { useRouteError, isRouteErrorResponse } from "react-router";
function RootErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</>
);
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}
createBrowserRouter([
{
path: "/",
ErrorBoundary: RootErrorBoundary,
Component: Root,
},
]);Throwing Errors in Loaders
import { data } from "react-router";
export async function loader({ params }) {
const record = await db.getRecord(params.id);
if (!record) {
throw data("Record Not Found", { status: 404 });
}
return record;
}Nested Error Boundaries
createBrowserRouter([
{
path: "/app",
ErrorBoundary: AppErrorBoundary,
children: [
{
path: "invoices/:id",
ErrorBoundary: InvoiceErrorBoundary,
Component: Invoice,
},
],
},
]);Protected Routes
Component-Based Protection
import { Navigate, useLocation } from "react-router";
function RequireAuth({ children }: { children: JSX.Element }) {
const auth = useAuth();
const location = useLocation();
if (!auth.user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// Route configuration
{
path: "/protected",
element: (
<RequireAuth>
<ProtectedPage />
</RequireAuth>
),
}
// In login handler - redirect back
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
function handleLogin() {
auth.signin(() => {
navigate(from, { replace: true });
});
}
}Middleware (Framework Mode Only)
Middleware requires Framework Mode and the future.v8_middleware flag. Export middleware from route modules:
// app/routes/dashboard.tsx (Framework Mode)
import { redirect, createContext } from "react-router";
export const userContext = createContext<User | null>(null);
export const middleware = [
async function authMiddleware({ request, context }, next) {
const userId = getUserId(request);
if (!userId) {
throw redirect("/login");
}
const user = await getUserById(userId);
context.set(userContext, user);
return next();
},
];
export async function loader({ context }: Route.LoaderArgs) {
const user = context.get(userContext);
return { user };
}Note: Middleware is NOT available in Data Mode (createBrowserRouter). Use loaders for auth checks in Data Mode.
Lazy Loading / Code Splitting
Data Mode Lazy Loading
createBrowserRouter([
{
path: "/app",
lazy: async () => {
const [Component, loader] = await Promise.all([
import("./app"),
import("./app-loader"),
]);
return { Component, loader };
},
},
]);Declarative Mode Lazy Loading
import React from "react";
const About = React.lazy(() => import("./pages/About"));
<Routes>
<Route
path="about"
element={
<React.Suspense fallback={<>Loading...</>}>
<About />
</React.Suspense>
}
/>
</Routes>Common Route Patterns
Optional Segments
{ path: ":lang?/categories" } // Optional dynamic segment
{ path: "users/:userId/edit?" } // Optional static segment at endCatch-All / Splat Routes
{ path: "files/*" }
// Access splat in loader
loader: ({ params }) => {
const filePath = params["*"]; // "path/to/file.txt"
}Multiple Params
{ path: "users/:userId/posts/:postId" }
// params.userId, params.postId available in loader/componentIndex vs Path Routes
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
// Index route - renders when parent path matches exactly
{ index: true, Component: DashboardHome },
// Path route - renders at parent + path
{ path: "settings", Component: Settings },
{ path: "profile", Component: Profile },
],
},
]);Index renders at: /dashboard Settings renders at: /dashboard/settings
Data Loading Patterns
Contents
- Basic Loader
- Parallel Data Loading
- Search Params in Loaders
- useSearchParams Hook
- Revalidation Control
- Framework Mode Loaders
---
Basic Loader
{
path: "/teams/:teamId",
loader: async ({ params, request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const team = await fetchTeam(params.teamId, query);
return { team, name: team.name };
},
Component: Team,
}
function Team() {
const data = useLoaderData();
return <h1>{data.name}</h1>;
}Parallel Data Loading
Nested routes load data in parallel automatically:
createBrowserRouter([
{
path: "/",
loader: rootLoader, // Loads in parallel
children: [
{
path: "project/:id",
loader: projectLoader, // Loads in parallel with rootLoader
},
],
},
]);Search Params in Loaders
{
path: "/search",
loader: async ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const page = url.searchParams.get("page") || "1";
return { results: await search(query, parseInt(page)) };
},
}
function SearchPage() {
const { results } = useLoaderData();
return (
<Form method="get">
<input type="text" name="q" />
<button type="submit">Search</button>
</Form>
);
}useSearchParams Hook
import { useSearchParams } from "react-router";
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get("q");
return (
<input
value={query || ""}
onChange={(e) => setSearchParams({ q: e.target.value })}
/>
);
}Revalidation Control
function shouldRevalidate({ currentUrl, nextUrl, formAction }) {
return currentUrl.pathname !== nextUrl.pathname;
}
createBrowserRouter([
{
path: "/data",
shouldRevalidate,
loader: dataLoader,
},
]);Framework Mode Loaders
// product.tsx
import { Route } from "./+types/product";
export async function loader({ params }: Route.LoaderArgs) {
const product = await getProduct(params.pid);
return { product };
}
export default function Product({ loaderData }: Route.ComponentProps) {
return <div>{loaderData.product.name}</div>;
}Navigation Patterns
Contents
- NavLink (Active Styling)
- Link (No Active Styling)
- Programmatic Navigation
- Redirect in Loaders
- Pending UI (Navigation State)
- Form Submission State
- Index Routes
- Layout Routes (No Path)
- Navigate Component
---
NavLink (Active Styling)
import { NavLink } from "react-router";
<NavLink to="/messages" end>
Messages
</NavLink>
// CSS styling
a.active { color: red; }
a.pending { animation: pulse 1s infinite; }
// Callback styling
<NavLink
to="/messages"
className={({ isActive, isPending }) =>
isActive ? "active" : isPending ? "pending" : ""
}
>
Messages
</NavLink>Link (No Active Styling)
import { Link } from "react-router";
<Link to="/login">Login</Link>
<Link to={{ pathname: "/search", search: "?q=term" }}>Search</Link>Programmatic Navigation
import { useNavigate } from "react-router";
function Component() {
const navigate = useNavigate();
// Use sparingly - only for non-user-initiated navigation
useEffect(() => {
if (inactivityTimeout) {
navigate("/logout");
}
}, [inactivityTimeout]);
// Or with options
navigate("/dashboard", { replace: true });
navigate(-1); // Go back
}Redirect in Loaders
import { redirect } from "react-router";
export async function loader({ request }) {
const user = await getUser(request);
if (!user) {
return redirect("/login");
}
return { user };
}Pending UI (Navigation State)
import { useNavigation } from "react-router";
function Root() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return (
<div>
{isNavigating && <GlobalSpinner />}
<Outlet />
</div>
);
}Form Submission State
function Component() {
const navigation = useNavigation();
const isSubmitting = navigation.formAction === "/recipes/new";
return (
<Form method="post" action="/recipes/new">
<button type="submit">
{isSubmitting ? "Saving..." : "Create Recipe"}
</button>
</Form>
);
}Index Routes
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
{ index: true, Component: DashboardHome }, // Renders at /dashboard
{ path: "settings", Component: Settings }, // Renders at /dashboard/settings
],
},
]);Layout Routes (No Path)
createBrowserRouter([
{
Component: MarketingLayout, // No path, just layout wrapper
children: [
{ index: true, Component: Home },
{ path: "contact", Component: Contact },
],
},
]);Navigate Component
import { Navigate, useLocation } from "react-router";
function RequireAuth({ children }) {
const auth = useAuth();
const location = useLocation();
if (!auth.user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}Related skills
How it compares
Use react-router-v7 for v7-specific loader and action patterns; use general React skills when the stack is not React Router data mode.
FAQ
When should you use a loader instead of useEffect in react-router-v7?
react-router-v7 recommends route loaders when data is required for correct first render or SSR boundaries, and useEffect only for client-only fetches triggered after mount by user actions, timers, or subscriptions.
When should you use Form instead of useFetcher in react-router-v7?
react-router-v7 directs developers to Form or route actions when the URL or history stack must change for bookmarking or back navigation, and to useFetcher for inline mutations that stay on the same route.
What React Router v7 setup modes does react-router-v7 document?
react-router-v7 compares Framework Mode with the Vite plugin, Data Mode via createBrowserRouter, and Declarative Mode, noting built-in SSR and auto-generated types in Framework Mode versus manual control in Data Mode.