React Router Basics with Data Router APIs
Learn React Router's modern data APIs. Install, create a browser router, define routes, navigate with Link and useNavigate, and use loaders and actions for data.
What you'll learn
- ✓How to install and bootstrap React Router
- ✓Defining routes with createBrowserRouter
- ✓Navigating with Link and useNavigate
- ✓Co-locating data fetching using loaders
- ✓Handling form submissions with actions
Prerequisites
- •Working knowledge of React components and JSX
- •Familiarity with useState and useEffect
React Router has been the dominant routing library for React for years. The modern data APIs (introduced in v6.4) shift it from a pure URL-to-component mapping into a full data layer. This article gets you started with the essentials: routes, navigation, and the loader and action pattern.
Installing
Add the package with your manager of choice.
// terminal
// npm install react-router-dom
// pnpm add react-router-dom
// yarn add react-router-dom
react-router-dom is the package you want for web apps. It re-exports the core API and adds DOM-specific bindings like Link and Form.
Creating a Browser Router
createBrowserRouter is the entry point for the data APIs. You hand it an array of route objects, then render a RouterProvider.
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Root from './routes/Root';
import Home from './routes/Home';
import Posts from './routes/Posts';
import Post from './routes/Post';
import NotFound from './routes/NotFound';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <NotFound />,
children: [
{ index: true, element: <Home /> },
{ path: 'posts', element: <Posts /> },
{ path: 'posts/:id', element: <Post /> },
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
A few details worth noting:
index: truemarks the default child rendered at the parent’s path.errorElementis the fallback if a loader, action, or render throws.- The
Rootcomponent is the layout; it renders shared chrome and an<Outlet />where children appear.
import { Outlet, Link } from 'react-router-dom';
export default function Root() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/posts">Posts</Link>
</nav>
<main>
<Outlet />
</main>
</div>
);
}
Linking and Programmatic Navigation
Use Link for declarative navigation. It renders an anchor tag and intercepts the click to perform client-side routing.
<Link to={`/posts/${post.id}`}>{post.title}</Link>
For programmatic navigation, use useNavigate.
import { useNavigate } from 'react-router-dom';
function NewPostButton() {
const navigate = useNavigate();
return <button onClick={() => navigate('/posts/new')}>New post</button>;
}
useNavigate also accepts options like replace (do not push a new history entry) and a negative number to go back: navigate(-1).
For active-state styling, NavLink exposes an isActive prop in its className callback.
import { NavLink } from 'react-router-dom';
<NavLink
to="/posts"
className={({ isActive }) => (isActive ? 'link active' : 'link')}
>
Posts
</NavLink>;
Reading URL Params and Query Strings
useParams returns the dynamic segments of the URL.
import { useParams } from 'react-router-dom';
function Post() {
const { id } = useParams();
// ...
}
useSearchParams is the equivalent for query strings, with a setter that updates the URL.
import { useSearchParams } from 'react-router-dom';
function Posts() {
const [searchParams, setSearchParams] = useSearchParams();
const tag = searchParams.get('tag') ?? 'all';
return (
<select value={tag} onChange={(e) => setSearchParams({ tag: e.target.value })}>
<option value="all">All</option>
<option value="react">React</option>
</select>
);
}
Loaders: Data Before Render
A loader is an async function attached to a route. It runs before the route’s component renders, so the component never sees an empty state.
import { useLoaderData } from 'react-router-dom';
async function postsLoader() {
const res = await fetch('/api/posts');
if (!res.ok) throw new Response('Failed to load', { status: 500 });
return res.json();
}
function Posts() {
const posts = useLoaderData() as Post[];
return (
<ul>
{posts.map((p) => (
<li key={p.id}>
<Link to={`/posts/${p.id}`}>{p.title}</Link>
</li>
))}
</ul>
);
}
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{ path: 'posts', element: <Posts />, loader: postsLoader },
],
},
]);
Loaders get a request object that includes the URL and params, so dynamic routes can fetch the right resource.
async function postLoader({ params }: { params: { id: string } }) {
const res = await fetch(`/api/posts/${params.id}`);
if (!res.ok) throw new Response('Not found', { status: 404 });
return res.json();
}
The key benefit over useEffect-based fetching: parallel data loading across nested routes, automatic revalidation after mutations, and no flash of empty UI. It is also closer to how server frameworks now think about data, which makes it easier to migrate code later. For more on the hook-based alternative, see our hooks introduction.
Actions: Handling Mutations
Where loaders are for reads, actions are for writes. An action runs when a Form (the router’s wrapper around the native <form>) submits to the route.
import { Form, redirect } from 'react-router-dom';
async function newPostAction({ request }: { request: Request }) {
const formData = await request.formData();
const res = await fetch('/api/posts', {
method: 'POST',
body: formData,
});
const post = await res.json();
return redirect(`/posts/${post.id}`);
}
function NewPost() {
return (
<Form method="post">
<input name="title" />
<textarea name="body" />
<button type="submit">Create</button>
</Form>
);
}
After the action completes, React Router automatically revalidates loaders on the matched routes, so the new data appears without a manual refetch. redirect is a helper that returns the right response shape for the router to follow.
Error Handling
Throwing from a loader or action transfers control to the nearest errorElement. Inside it, useRouteError gives you the thrown value.
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
function NotFound() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <p>{error.status} {error.statusText}</p>;
}
return <p>Something went wrong.</p>;
}
This is structurally similar to error boundaries for component rendering, but it covers async data flow as well.
Putting It Together
A small blog could look like this.
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <NotFound />,
children: [
{ index: true, element: <Home /> },
{ path: 'posts', element: <Posts />, loader: postsLoader },
{ path: 'posts/new', element: <NewPost />, action: newPostAction },
{ path: 'posts/:id', element: <Post />, loader: postLoader },
],
},
]);
Each route is a small, self-contained module: a component, optionally a loader, optionally an action. Adding a screen is mostly adding a node to the tree.
Wrap up
The data router APIs make React Router feel less like a URL switch and more like a framework. Loaders move data fetching out of useEffect and into the routing layer where it belongs. Actions give you a structured way to mutate data and trigger fresh loads. Start small, define routes for your pages, then introduce loaders and actions as your data needs grow.