React Router v7 Guide: File-Based Routing, Loaders, Actions
React Router v7 ships file-based routing, loaders, and actions that change how you build React apps. Here's what actually changed and how to use it.
What Changed in React Router v7
React Router v7 landed in late 2024 and it's basically Remix under the hood now. Not metaphorically — the Remix team merged the two projects, which means all the loader/action data patterns that Remix popularized since 2022 are now first-class citizens in plain React Router. If you've been on v6, the mental model shift is real.
The biggest thing is the framework mode versus library mode split. You can still use React Router as a pure client-side library like you always have. But if you opt into framework mode, you get file-based routing, server-side loaders, form actions, and all the Remix-style conventions. Worth noting: these are genuinely separate modes, not feature flags on the same setup.
Honestly, the naming is the confusing part. react-router is still the package. @react-router/dev is the Vite plugin you add for framework mode. If you come from Next.js, a lot of this will feel familiar — but the conventions are different enough that you can't just assume things work the same way.
One more thing — v7 dropped React 17 support. You need React 18+ for the streaming features to work, and some of the new suspense-based data loading relies on concurrent features that just don't exist below 18.
Setting Up File-Based Routing
In framework mode, your routes live in an app/routes/ directory. The file name is the route. app/routes/dashboard.tsx becomes /dashboard. Simple. Nested routes use dot notation — app/routes/dashboard.settings.tsx maps to /dashboard/settings and renders inside the parent layout automatically.
npm create react-router@latest my-app
cd my-app
npm install
npm run devThe project scaffold gives you an app/root.tsx (your root layout), an app/routes/ folder, and a vite.config.ts already wired up with the @react-router/dev/vite plugin. You don't write a router config by hand — the file system is the config. That said, you can still define routes programmatically in app/routes.ts if file naming conventions get awkward for your project structure.
// app/routes/dashboard.tsx
export default function Dashboard() {
return <h1>Dashboard</h1>;
}Dynamic segments use the $ prefix. app/routes/posts.$postId.tsx captures /posts/123 and gives you params.postId in your loader and component. You can have multiple dynamic segments in the same path — posts.$category.$postId.tsx works exactly how you'd expect. Splat routes use $.tsx for catch-all matching at the end of any path.
Loaders: Server Data Fetching Done Right
Loaders are the file-based routing feature that'll actually change how you think about data fetching. Instead of fetching inside useEffect or pulling data from a global store, you export a loader function from your route file. It runs on the server before the component renders, and the component just calls useLoaderData() to get the result.
// app/routes/posts.$postId.tsx
import { useLoaderData } from 'react-router';
import type { Route } from './+types/posts.$postId';
export async function loader({ params }: Route.LoaderArgs) {
const post = await db.post.findUnique({
where: { id: params.postId },
});
if (!post) throw new Response('Not Found', { status: 404 });
return { post };
}
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return <article>{post.title}</article>;
}That Route.LoaderArgs type is auto-generated by the dev plugin at build time — so params.postId is typed correctly without you writing a single interface. This is one of those details that sounds minor but saves you from a whole class of runtime bugs where you typo a param name and nobody catches it until production.
In practice, loaders also handle auth redirects cleanly. Throw a redirect('/login') response from your loader and the router handles it before any component code runs. No more useEffect dancing, no flash of unauthorized content at 0px. The component literally never mounts if the user shouldn't see the page.
Look, if you've been reaching for React Query for every server fetch in a CSR app, loaders won't replace that for client-side refetching. But for initial page data? They're cleaner. The data is there when the component renders, full stop.
Actions: Mutations Without useEffect Spaghetti
Actions are the mutation side of loaders. Export an action function from your route file, and any <Form> that POSTs to that route will call it. No onSubmit handler, no fetch call, no state management for pending status — the framework handles it.
// app/routes/posts.new.tsx
import { Form, redirect, useActionData } from 'react-router';
import type { Route } from './+types/posts.new';
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const title = String(formData.get('title'));
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' };
}
const post = await db.post.create({ data: { title } });
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="title" />
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Create</button>
</Form>
);
}The useNavigation() hook gives you pending state — navigation.state === 'submitting' is true while the action is running. So a loading spinner is literally three lines. Quick aside: this works progressively too, meaning if JavaScript fails to load, the form still submits as a plain HTML POST and the action still runs. You get resilient forms for free.
Where it gets interesting is optimistic UI. useFetcher() lets you submit to any route's action without navigating, and fetcher.formData gives you the submitted data immediately so you can render optimistic state before the server responds. It's the same pattern Remix popularized in 2022, and it's genuinely good.
Layout Routes and Nested Data
Nested routes share layouts through outlet composition. A file named app/routes/dashboard.tsx that renders <Outlet /> will wrap all dashboard.* child routes automatically. Each segment in the hierarchy can have its own loader, so data fetches run in parallel — not waterfall.
// app/routes/dashboard.tsx — parent layout
import { Outlet, useLoaderData } from 'react-router';
export async function loader() {
const user = await getCurrentUser();
return { user };
}
export default function DashboardLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<div>
<nav>Hello, {user.name}</nav>
<Outlet />
</div>
);
}The child route's loader runs at the same time as the parent's loader. By the time both resolve, React Router has all the data and renders the full tree in one shot. Compare this to the old pattern of fetching in useEffect inside each component — you'd get a waterfall where each component waited for its parent to mount before starting its own fetch.
If you want a layout that doesn't add a URL segment, prefix the file with an underscore. _dashboard.tsx creates a layout route, but the actual URLs are just /analytics, /settings, etc. — no /dashboard/ prefix in the path. This is useful for wrapping a group of routes in a shared authenticated layout without it showing up in the URL.
Worth noting: pathless layout routes are one of those file-naming conventions that trips people up the first time. The _ prefix means "wrap these routes in this layout, but don't add a segment to the path." Once it clicks, it clicks.
Error Boundaries and 404 Handling
Every route can export an ErrorBoundary component. When the loader throws, when the action throws, when the component throws — React Router catches it and renders your ErrorBoundary instead. This is scoped to the route, so a broken child route doesn't take down the parent layout.
// app/routes/posts.$postId.tsx
import { isRouteErrorResponse, useRouteError } 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>
);
}
throw error; // re-throw unexpected errors to the root boundary
}The isRouteErrorResponse check lets you differentiate between deliberate HTTP responses you threw yourself (like that 404 from earlier) versus unexpected runtime errors. This is a genuinely useful distinction — you'd handle a missing database record differently than you'd handle a crashed third-party API call.
For a catch-all 404, create app/routes/$.tsx. It matches any URL that didn't hit another route. Pair it with an ErrorBoundary in app/root.tsx and you've got complete error coverage across the entire app in maybe 40 lines of code total.
Migrating From v6 and Integrating With Your UI
If you're on React Router v6 with createBrowserRouter and RouterProvider, the upgrade to v7 in library mode is mostly a package bump. The <Routes> / <Route> API still works. The breaking changes are minor — check the official changelog, but for most apps it's a few import path renames and you're done.
Framework mode is a bigger shift. You're not just upgrading a package, you're adopting a build convention. It's worth it for new projects or apps that are heavily server-data-driven. For an existing CSR SPA that fetches everything client-side, the migration is more involved and maybe not worth it right now.
Where React Router v7 really shines is when you pair it with a component library that handles the UI side so you can focus on the routing and data layer. If you're building dashboards or content-heavy apps, check out Empire UI — the components are designed to work with whatever data pattern you're using, loaders included. Things like glassmorphism components or the full template set at /templates drop into a loader-based setup without needing a global state wrapper.
One thing people miss: in framework mode, you can still run in pure SPA mode without a Node.js server. Set ssr: false in your React Router config and you get static file output — client-side rendering only, no server required. You lose server loaders (they become client loaders that run in the browser), but you keep all the file-based routing conventions. Good middle ground if you're on a static host. If you're already using tools like the gradient generator or other Empire UI tools for your design workflow, this matters — static deploys are still very much a valid target.
FAQ
Pretty much, yes. The Remix team merged the two projects into React Router v7, so the loaders, actions, and file-based routing you knew from Remix are now in React Router. You can run in library mode (no file-based routing) or framework mode (full Remix-style conventions). Same package, same npm install.
In framework mode with SSR enabled, loaders run on the server before the component renders. In SPA mode (ssr: false), they're called client loaders and run in the browser. You get the same API either way — the behavior differs based on your config.
Yes, and that's the recommended setup. Install @react-router/dev and add the Vite plugin to your vite.config.ts. The scaffold from npm create react-router@latest sets this up for you. HMR works well and the type generation hooks into the build automatically.
useLoaderData gives you the data from the current route's loader — it's for the initial page data. useFetcher lets you call any route's loader or action without triggering a navigation, which is what you want for background mutations, search-as-you-type, or optimistic UI updates.