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.

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>
  )
}
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 /> },
])
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>
  )
}