Skip to main content

Routing Fundamentals

Routing connects URLs to components, enabling multi-page experiences in single-page applications.

Declarative Routes

Define routes as components for clear structure

Dynamic Segments

URL parameters for dynamic content

Nested Routes

Hierarchical routing with shared layouts

Navigation Guards

Protect routes with authentication checks

Route Configuration

React Router Setup

The most popular routing solution for React applications.
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

// Define routes configuration
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      {
        index: true,
        element: <HomePage />,
      },
      {
        path: 'about',
        element: <AboutPage />,
      },
      {
        path: 'products',
        element: <ProductsLayout />,
        children: [
          {
            index: true,
            element: <ProductsList />,
          },
          {
            path: ':productId',
            element: <ProductDetail />,
            loader: productLoader,
          },
        ],
      },
      {
        path: 'dashboard',
        element: <ProtectedRoute><Dashboard /></ProtectedRoute>,
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}
React Router v6+ uses data routers with loaders and actions for better data fetching patterns.

Programmatic Navigation

import { useNavigate, useLocation } from 'react-router-dom';

function LoginForm() {
  const navigate = useNavigate();
  const location = useLocation();
  
  const handleSubmit = async (credentials: Credentials) => {
    try {
      await login(credentials);
      
      // Redirect to the page user was trying to access
      const from = location.state?.from?.pathname || '/dashboard';
      navigate(from, { replace: true });
    } catch (error) {
      toast.error('Login failed');
    }
  };
  
  return <form onSubmit={handleSubmit}>{/* form fields */}</form>;
}
import { Link, NavLink } from 'react-router-dom';

// Basic link
<Link to="/about">About Us</Link>

// Link with state
<Link
  to="/products/123"
  state={{ from: 'search-results' }}
>
  View Product
</Link>

// NavLink with active styling
<NavLink
  to="/dashboard"
  className={({ isActive }) =>
    isActive ? 'nav-link active' : 'nav-link'
  }
>
  Dashboard
</NavLink>

// Relative navigation
<Link to="..">Back to list</Link>
<Link to="./edit">Edit</Link>
Use <Link> for navigation instead of <a> tags to avoid full page reloads in SPAs.

Search Params and Filters

import { useSearchParams } from 'react-router-dom';

function ProductsList() {
  const [searchParams, setSearchParams] = useSearchParams();
  
  const category = searchParams.get('category') || 'all';
  const sort = searchParams.get('sort') || 'name';
  const page = parseInt(searchParams.get('page') || '1', 10);
  
  const updateFilter = (key: string, value: string) => {
    setSearchParams(prev => {
      const next = new URLSearchParams(prev);
      next.set(key, value);
      next.set('page', '1'); // Reset page on filter change
      return next;
    });
  };
  
  const clearFilters = () => {
    setSearchParams({});
  };
  
  return (
    <div>
      <select
        value={category}
        onChange={(e) => updateFilter('category', e.target.value)}
      >
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>
      
      <select
        value={sort}
        onChange={(e) => updateFilter('sort', e.target.value)}
      >
        <option value="name">Name</option>
        <option value="price">Price</option>
        <option value="date">Date Added</option>
      </select>
      
      <button onClick={clearFilters}>Clear Filters</button>
      
      <ProductGrid category={category} sort={sort} page={page} />
    </div>
  );
}

Dynamic Routes

Route Parameters

import { useParams, useLoaderData } from 'react-router-dom';

// Route configuration
{
  path: '/blog/:slug',
  element: <BlogPost />,
  loader: async ({ params }) => {
    return fetchBlogPost(params.slug);
  },
}

// Component using params
function BlogPost() {
  const { slug } = useParams<{ slug: string }>();
  const post = useLoaderData() as BlogPost;
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p className="meta">Posted on {post.date}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Optional Parameters

// Route with optional segment
{
  path: '/shop/:category?',
  element: <Shop />,
}

function Shop() {
  const { category } = useParams<{ category?: string }>();
  
  // Handle both /shop and /shop/electronics
  const products = useProducts(category || 'all');
  
  return <ProductGrid products={products} />;
}

Wildcard Routes

// Catch-all route for documentation
{
  path: '/docs/*',
  element: <Documentation />,
}

function Documentation() {
  const location = useLocation();
  // location.pathname: "/docs/getting-started/installation"
  
  const path = location.pathname.replace('/docs/', '');
  const content = useDocContent(path);
  
  return <MarkdownRenderer content={content} />;
}

Route Guards & Protection

Authentication Guard

import { Navigate, useLocation } from 'react-router-dom';

function ProtectedRoute({ children }: { children: ReactNode }) {
  const { user, isLoading } = useAuth();
  const location = useLocation();
  
  if (isLoading) {
    return <LoadingSpinner />;
  }
  
  if (!user) {
    // Redirect to login, save attempted location
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  
  return <>{children}</>;
}

// Usage
{
  path: '/dashboard',
  element: (
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  ),
}

Role-Based Access

interface RoleGuardProps {
  children: ReactNode;
  requiredRoles: string[];
  fallback?: ReactNode;
}

function RoleGuard({ children, requiredRoles, fallback }: RoleGuardProps) {
  const { user } = useAuth();
  
  const hasRequiredRole = requiredRoles.some(role =>
    user?.roles.includes(role)
  );
  
  if (!hasRequiredRole) {
    return fallback || <Navigate to="/unauthorized" replace />;
  }
  
  return <>{children}</>;
}

// Usage
<RoleGuard requiredRoles={['admin', 'editor']}>
  <AdminPanel />
</RoleGuard>
import { useBlocker } from 'react-router-dom';

function FormWithConfirmation() {
  const [formData, setFormData] = useState({});
  const [isDirty, setIsDirty] = useState(false);
  
  // Block navigation if form has unsaved changes
  const blocker = useBlocker(
    ({ currentLocation, nextLocation }) =>
      isDirty && currentLocation.pathname !== nextLocation.pathname
  );
  
  useEffect(() => {
    if (blocker.state === 'blocked') {
      const confirm = window.confirm(
        'You have unsaved changes. Are you sure you want to leave?'
      );
      
      if (confirm) {
        blocker.proceed();
      } else {
        blocker.reset();
      }
    }
  }, [blocker]);
  
  return (
    <form onChange={() => setIsDirty(true)}>
      {/* form fields */}
    </form>
  );
}
Always save navigation blockers for scenarios where losing data would be problematic, like unsaved forms.

Nested Routes & Layouts

Shared Layouts

import { Outlet } from 'react-router-dom';

// Dashboard layout with sidebar
function DashboardLayout() {
  return (
    <div className="dashboard">
      <DashboardSidebar />
      <main className="dashboard-content">
        {/* Child routes render here */}
        <Outlet />
      </main>
    </div>
  );
}

// Route configuration
{
  path: '/dashboard',
  element: <DashboardLayout />,
  children: [
    { index: true, element: <DashboardHome /> },
    { path: 'analytics', element: <Analytics /> },
    { path: 'settings', element: <Settings /> },
    {
      path: 'team',
      element: <TeamLayout />,
      children: [
        { index: true, element: <TeamList /> },
        { path: ':teamId', element: <TeamDetail /> },
      ],
    },
  ],
}

Outlet Context

interface DashboardContextType {
  isSidebarOpen: boolean;
  toggleSidebar: () => void;
}

function DashboardLayout() {
  const [isSidebarOpen, setIsSidebarOpen] = useState(true);
  
  const context: DashboardContextType = {
    isSidebarOpen,
    toggleSidebar: () => setIsSidebarOpen(prev => !prev),
  };
  
  return (
    <div className="dashboard">
      <DashboardSidebar isOpen={isSidebarOpen} />
      <Outlet context={context} />
    </div>
  );
}

// Child route accessing context
function Analytics() {
  const { isSidebarOpen, toggleSidebar } = useOutletContext<DashboardContextType>();
  
  return (
    <div>
      <button onClick={toggleSidebar}>Toggle Sidebar</button>
      {/* Analytics content */}
    </div>
  );
}

Loading States & Error Handling

Route Loaders

// Data loader for route
async function productLoader({ params }: LoaderFunctionArgs) {
  const product = await fetchProduct(params.productId);
  
  if (!product) {
    throw new Response('Not Found', { status: 404 });
  }
  
  return { product };
}

// Route configuration
{
  path: '/products/:productId',
  element: <ProductDetail />,
  loader: productLoader,
  errorElement: <ProductError />,
}

// Component using loaded data
function ProductDetail() {
  const { product } = useLoaderData() as { product: Product };
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
    </div>
  );
}

Error Boundaries

import { useRouteError, isRouteErrorResponse } from 'react-router-dom';

function ErrorPage() {
  const error = useRouteError();
  
  if (isRouteErrorResponse(error)) {
    return (
      <div className="error-page">
        <h1>{error.status}</h1>
        <p>{error.statusText}</p>
        {error.data?.message && <p>{error.data.message}</p>}
      </div>
    );
  }
  
  return (
    <div className="error-page">
      <h1>Oops!</h1>
      <p>Sorry, an unexpected error occurred.</p>
    </div>
  );
}

Best Practices

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

const router = createBrowserRouter([
  {
    path: '/dashboard',
    element: (
      <Suspense fallback={<LoadingSpinner />}>
        <Dashboard />
      </Suspense>
    ),
  },
]);
Split large routes to reduce initial bundle size.
// routes.ts
export const ROUTES = {
  HOME: '/',
  PRODUCTS: '/products',
  PRODUCT_DETAIL: (id: string) => `/products/${id}`,
  DASHBOARD: '/dashboard',
  SETTINGS: '/dashboard/settings',
} as const;

// Usage
<Link to={ROUTES.PRODUCT_DETAIL('123')}>View Product</Link>
Avoid hardcoding paths throughout your app.
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function ScrollToTop() {
  const { pathname } = useLocation();
  
  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);
  
  return null;
}

// Add to app root
<Router>
  <ScrollToTop />
  <Routes>{/* routes */}</Routes>
</Router>
{
  path: '*',
  element: <NotFoundPage />,
}

function NotFoundPage() {
  return (
    <div className="not-found">
      <h1>404 - Page Not Found</h1>
      <p>The page you're looking for doesn't exist.</p>
      <Link to="/">Go Home</Link>
    </div>
  );
}
Good routing makes navigation intuitive and URLs shareable. Invest time in getting your routing structure right early.

Build docs developers (and LLMs) love