React Router v6: Navigation, Loaders and Actions (2026)
React Router v6 introduced a fundamentally new data layer — loaders for fetching data before a route renders and actions for handling form mutations. Combined with nested routes and the file-based mental model, v6 brings React Router much closer to how frameworks like Remix and Next.js handle routing. This guide covers everything from basic navigation to advanced data patterns.
Table of Contents
Setup and Basic Routing
npm install react-router-dom
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { Root } from './pages/Root'
import { Home } from './pages/Home'
import { About } from './pages/About'
import { ProductDetail } from './pages/ProductDetail'
const router = createBrowserRouter([
{
path: '/',
element: <Root />, // Layout with <Outlet />
children: [
{ index: true, element: <Home /> },
{ path: 'about', element: <About /> },
{ path: 'products/:id', element: <ProductDetail /> },
],
},
])
function App() {
return <RouterProvider router={router} />
}
// Root.tsx — renders the layout + child routes
import { Outlet, Link, NavLink } from 'react-router-dom'
function Root() {
return (
<div>
<nav>
<NavLink to="/" end className={({ isActive }) => isActive ? 'active' : ''}>Home</NavLink>
<NavLink to="/about">About</NavLink>
</nav>
<main>
<Outlet /> {/* Child route renders here */}
</main>
</div>
)
}
Nested Routes
const router = createBrowserRouter([
{
path: '/dashboard',
element: <DashboardLayout />,
children: [
{ index: true, element: <Overview /> }, // /dashboard
{ path: 'analytics', element: <Analytics /> }, // /dashboard/analytics
{
path: 'settings',
element: <SettingsLayout />,
children: [
{ index: true, element: <GeneralSettings /> }, // /dashboard/settings
{ path: 'security', element: <SecuritySettings /> }, // /dashboard/settings/security
],
},
],
},
])
// DashboardLayout.tsx
function DashboardLayout() {
return (
<div className="dashboard">
<Sidebar />
<main>
<Outlet />
</main>
</div>
)
}
Navigation Hooks
import { useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom'
function ProductDetail() {
const { id } = useParams() // /products/:id
const navigate = useNavigate()
const location = useLocation() // current URL info
function goBack() {
navigate(-1) // browser back
}
function goToCart() {
navigate('/cart', {
state: { from: location.pathname }, // pass state
replace: true, // replace history entry
})
}
return (
<div>
<button onClick={goBack}>Back</button>
<h1>Product {id}</h1>
</div>
)
}
Loaders: Data Before Render
Loaders fetch data in parallel with the route transition — no useEffect needed:
// Define loader alongside the route
async function productLoader({ params }) {
const res = await fetch(`/api/products/${params.id}`)
if (!res.ok) throw new Response('Not Found', { status: 404 })
return res.json()
}
const router = createBrowserRouter([
{
path: 'products/:id',
element: <ProductDetail />,
loader: productLoader,
errorElement: <ProductError />,
},
])
// In the component — useLoaderData returns the loader result
import { useLoaderData } from 'react-router-dom'
function ProductDetail() {
const product = useLoaderData() // Typed: awaited return of productLoader
return <h1>{product.name}</h1>
}
// TypeScript — type the loader return
import { type LoaderFunctionArgs } from 'react-router-dom'
export async function loader({ params }: LoaderFunctionArgs) {
return fetchProduct(params.id!)
}
// Infer return type in component:
// const product = useLoaderData() as Awaited<ReturnType<typeof loader>>
Actions: Form Mutations
import { Form, useActionData, redirect } from 'react-router-dom'
// Action handles form submission
async function createPostAction({ request }) {
const formData = await request.formData()
const title = formData.get('title')
const body = formData.get('body')
const errors = {}
if (!title) errors.title = 'Title is required'
if (Object.keys(errors).length) return errors // Return errors to component
await createPost({ title, body })
return redirect('/posts') // Redirect on success
}
const router = createBrowserRouter([
{
path: 'posts/new',
element: <NewPost />,
action: createPostAction,
},
])
function NewPost() {
const errors = useActionData() // Returned errors from action
return (
// Form with method="post" automatically calls the action
<Form method="post">
<input name="title" />
{errors?.title && <p style={{color:'red'}}>{errors.title}</p>}
<textarea name="body" />
<button type="submit">Create</button>
</Form>
)
}
Error Elements
import { useRouteError, isRouteErrorResponse } from 'react-router-dom'
function ErrorPage() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
// Thrown Response objects
if (error.status === 404) return <div>Page not found</div>
if (error.status === 401) return <div>Unauthorized</div>
return <div>Error {error.status}: {error.statusText}</div>
}
// Unexpected errors
return <div>Something went wrong</div>
}
Lazy Routes
const router = createBrowserRouter([
{
path: 'dashboard',
lazy: async () => {
const { Dashboard } = await import('./pages/Dashboard')
return { Component: Dashboard }
},
},
{
path: 'reports',
lazy: async () => {
// Can also lazy-load loader and action
const { loader, action, Reports } = await import('./pages/Reports')
return { loader, action, Component: Reports }
},
},
])
Protected Routes
import { Navigate, Outlet, useLocation } from 'react-router-dom'
function RequireAuth() {
const { user } = useAuthStore()
const location = useLocation()
if (!user) {
// Redirect to login, preserving intended destination
return <Navigate to="/login" state={{ from: location }} replace />
}
return <Outlet />
}
const router = createBrowserRouter([
{
element: <RequireAuth />,
children: [
{ path: '/dashboard', element: <Dashboard /> },
{ path: '/profile', element: <Profile /> },
],
},
{ path: '/login', element: <Login /> },
])
Search Params
import { useSearchParams } from 'react-router-dom'
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams()
const page = Number(searchParams.get('page') ?? '1')
const sort = searchParams.get('sort') ?? 'newest'
const category = searchParams.get('category') ?? ''
function handlePageChange(newPage) {
setSearchParams(prev => {
prev.set('page', String(newPage))
return prev
})
}
function handleSortChange(newSort) {
setSearchParams({ page: '1', sort: newSort, category })
}
return (
<div>
<select value={sort} onChange={e => handleSortChange(e.target.value)}>
<option value="newest">Newest</option>
<option value="price-asc">Price Low to High</option>
</select>
{/* Render products */}
<Pagination page={page} onChange={handlePageChange} />
</div>
)
}