Skip to main content

πŸ—ΊοΈ Route Configuration

Sprinkling <Route> tags through your JSX works for a handful of pages. But real apps have dozens of routes, shared layouts, error screens, and data to load. This lesson shows the grown-up way: describing your routes as data with createBrowserRouter, so the router can do far more than just render.

Week 5 · Day 2 (Tuesday: React Router) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Contrast the JSX <Routes> style with the object-based createBrowserRouter style
  • Define a route tree as an array of objects with path, element, and children
  • Build shared layouts with a parent route and an <Outlet />
  • Use index routes and a catch-all to handle defaults and 404s
  • Attach an errorElement boundary and read errors with useRouteError
  • Split routes into feature modules and lazy-load heavy screens

Estimated Time: 60 minutes

Practice: Convert a JSX route tree to a config object and add a nested layout with an error boundary.

In This Lesson

Why configure routes as data?

Think of route configuration like a building's blueprint. Each route is a room; the configuration says how the rooms connect, which ones share a hallway, and what happens if someone opens a door that leads nowhere. When routes are just JSX scattered in components, that blueprint is hard to see. When routes are a single tree of plain objects, the whole structure is visible at a glance β€” and, crucially, the router can read ahead to load data and catch errors before a component ever renders.

graph TD A["Root / (MainLayout)"] --> B["index β†’ Home"] A --> C["about β†’ About"] A --> D["products"] D --> E["index β†’ ProductList"] D --> F[":productId β†’ ProductDetail"] A --> G["* β†’ NotFound"]

That tree is exactly what you'll build in code below. Notice how products has children of its own β€” nesting is the heart of route configuration.

Two styles, one router

React Router v6+ lets you declare routes two ways. Both use the same matching engine; you pick based on what you need.

JSX style β€” <Routes>Data style β€” createBrowserRouter
Routes written as JSX elementsRoutes written as plain objects
Great for small apps & quick demosGreat for real apps that scale
No data loaders / actionsUnlocks loaders, actions, errorElement
Config lives inside a componentConfig lives as exportable data

πŸ’‘ The direction of travel

The data style is where React Router is heading β€” it powers the loaders, actions, and error handling that make routing feel like a mini-framework. We introduce it here and use it as the default for the rest of the course. You'll still see the JSX style in the wild and in quick prototypes, so both are worth knowing.

Object-based configuration

Instead of JSX, you describe each route as an object. The keys mirror the props you already know: path, element, plus a new children array for nesting. You hand the tree to createBrowserRouter and render a single <RouterProvider>.

import { createBrowserRouter, RouterProvider } from 'react-router-dom';

// Routes are just data β€” an array of objects
const router = createBrowserRouter([
  {
    path: '/',
    element: <MainLayout />,     // shared shell (nav, footer)
    errorElement: <ErrorPage />, // catches errors for this branch
    children: [
      { index: true,        element: <Home /> },     // "/"
      { path: 'about',      element: <About /> },    // "/about"
      {
        path: 'products',
        element: <Products />,                        // "/products"
        children: [
          { index: true,       element: <ProductList /> },
          { path: ':productId', element: <ProductDetail /> }, // "/products/42"
        ],
      },
      { path: '*', element: <NotFound /> },          // catch-all 404
    ],
  },
]);

function App() {
  // One provider replaces <BrowserRouter> + <Routes>
  return <RouterProvider router={router} />;
}

πŸ“– Note the child paths are relative

Child path values don't start with a slash: 'about', not '/about'. The router joins them onto the parent's path for you, so the child of / resolves to /about. Leading slashes on children are a common source of "why won't this match?" confusion.

Nested routes & layouts

The real payoff of nesting is shared layouts. A parent route renders the chrome that every child shares β€” the nav bar, sidebar, footer β€” and marks the spot where the active child should appear with an <Outlet />. Think of <Outlet /> as a picture frame: the layout stays on the wall while the router swaps the photo inside it.

A layout route holds the header, nav, and footer; the Outlet swaps the child page inside MainLayout (parent route) Header + Nav (always shown) <Outlet /> child route renders here (Home / About / Products…) Footer (always shown)
Only the framed area changes as you navigate; the layout around it persists, so it never re-mounts or flickers.
import { Outlet, Link } from 'react-router-dom';

function MainLayout() {
  return (
    <div className="layout">
      <header>
        <nav>
          <Link to="/">Home</Link>
          <Link to="/products">Products</Link>
          <Link to="/about">About</Link>
        </nav>
      </header>

      <main>
        <Outlet /> {/* ← the matched child route renders here */}
      </main>

      <footer>Β© 2026</footer>
    </div>
  );
}

You can have different layouts for different sections β€” a public shell, an auth shell, a dashboard shell β€” just by giving each its own parent route with its own layout component and <Outlet />.

const router = createBrowserRouter([
  {
    element: <MainLayout />,      // public pages share this shell
    children: [
      { path: '/',      element: <Home /> },
      { path: 'about',  element: <About /> },
    ],
  },
  {
    element: <AuthLayout />,      // login/register share a different shell
    children: [
      { path: 'login',    element: <Login /> },
      { path: 'register', element: <Register /> },
    ],
  },
]);

Index routes & the 404

Two special route entries handle the edges of your tree.

The index route β€” the default child

An index: true route is what renders inside a parent's <Outlet /> when the URL matches the parent exactly. It's the "home page" of that branch. It has no path because it is the parent's path.

{
  path: 'products',
  element: <ProductsLayout />,
  children: [
    { index: true,        element: <ProductList /> },   // shown at /products
    { path: ':productId', element: <ProductDetail /> }, // shown at /products/42
  ],
}

The catch-all β€” your 404

A route with path: '*' matches anything no other route claimed. Put one at the top level so a typo'd URL shows a helpful page instead of a blank screen.

function NotFound() {
  return (
    <div>
      <h1>404 β€” Page Not Found</h1>
      <p>The page you're looking for doesn't exist.</p>
      <Link to="/">Go to Homepage</Link>
    </div>
  );
}

// somewhere in the route tree:
{ path: '*', element: <NotFound /> }

βœ… Index vs path: ''

Use index: true for the default child rather than path: ''. It reads clearly ("this is the index of this section") and avoids ambiguity when a branch also has a catch-all.

Error boundaries

What happens when a component throws, or a data load fails? Without a plan, the user sees a white screen and a scary console error. The data router lets you attach an errorElement to any route: if anything in that route (or its children) throws, the router renders the error element instead of crashing the whole app.

graph TD A[Route renders / loads] --> B{Threw an error?} B -->|No| C[Show the normal element] B -->|Yes| D[Bubble up to nearest errorElement] D --> E[Render friendly error UI]

Inside the error element, the useRouteError hook gives you the thrown value, and isRouteErrorResponse tells you whether it was an HTTP-style response (like a 404 or 401) so you can tailor the message.

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

function ErrorPage() {
  const error = useRouteError();

  // Router-thrown responses (e.g. a 404 from a loader)
  if (isRouteErrorResponse(error)) {
    return (
      <div className="error-page">
        <h1>{error.status} β€” {error.statusText}</h1>
        <p>{error.data?.message ?? 'Something went wrong.'}</p>
        <Link to="/">Go home</Link>
      </div>
    );
  }

  // Plain JavaScript errors
  return (
    <div className="error-page">
      <h1>Oops! Something broke.</h1>
      <p>{error?.message ?? 'Unknown error'}</p>
      <Link to="/">Go home</Link>
    </div>
  );
}

const router = createBrowserRouter([
  {
    path: '/',
    element: <MainLayout />,
    errorElement: <ErrorPage />, // one boundary protects this whole branch
    children: [ /* ... */ ],
  },
]);

πŸ’‘ Errors bubble to the nearest boundary

Just like try/catch, an error rises until it finds the nearest errorElement. Put one at the root to guarantee no crash goes unhandled, and add more specific ones on risky branches (say, a data-heavy dashboard) for tailored messages.

Organizing & lazy-loading

As the tree grows, keep it readable by splitting routes into per-feature modules and importing them into the root config.

// blog.routes.js β€” one feature's routes, exported as data
export const blogRoutes = {
  path: 'blog',
  children: [
    { index: true,          element: <BlogList /> },
    { path: ':slug',        element: <BlogPost /> },
    { path: 'tag/:tagName', element: <TaggedPosts /> },
  ],
};

// router.js β€” assemble the app from feature modules
import { blogRoutes } from './blog.routes';
import { shopRoutes } from './shop.routes';

const router = createBrowserRouter([
  { path: '/', element: <MainLayout />, children: [blogRoutes, shopRoutes] },
]);

Lazy-load heavy screens

You don't want the code for an admin dashboard downloaded by a visitor who never opens it. The data router supports a route-level lazy that loads the screen's code only when its URL is visited β€” shrinking your initial bundle.

const router = createBrowserRouter([
  {
    path: '/dashboard',
    // Code for Dashboard is fetched on demand, not up front
    lazy: () => import('./pages/Dashboard'), // module exports { Component, loader? }
  },
]);

πŸ“– Two ways to lazy-load

Route-level lazy is the data-router idiom. In the JSX style you'd instead use React's lazy() + <Suspense fallback={...}> around the element. Both split your bundle; pick the one that matches your routing style.

Practice & Quiz

πŸ‹οΈ Exercise 1: JSX β†’ config object

Goal: Convert this JSX route tree into a createBrowserRouter configuration array.

<Routes>
  <Route element={<MainLayout />}>
    <Route path="/" element={<Home />} />
    <Route path="about" element={<About />} />
  </Route>
  <Route path="*" element={<NotFound />} />
</Routes>
πŸ’‘ Hint

The layout route becomes an object with element and a children array. Use index: true for the "/" home page. Child paths lose their leading slash.

βœ… Solution
import { createBrowserRouter } from 'react-router-dom';

const router = createBrowserRouter([
  {
    element: <MainLayout />,
    children: [
      { index: true,   element: <Home /> },
      { path: 'about', element: <About /> },
    ],
  },
  { path: '*', element: <NotFound /> },
]);

πŸ‹οΈ Exercise 2: Add a protected error boundary

Goal: Give a /settings route its own errorElement so a crash there shows a friendly message instead of taking down the app.

βœ… Solution
import { useRouteError } from 'react-router-dom';

function SettingsError() {
  const error = useRouteError();
  return <p>Couldn't load settings: {error?.message}</p>;
}

const settingsRoute = {
  path: 'settings',
  element: <Settings />,
  errorElement: <SettingsError />, // scoped to just this branch
};

🎯 Quick Quiz

Question 1: What does an <Outlet /> do inside a layout route?

Question 2: Which entry renders when the URL matches the parent's path exactly?

Question 3: Inside an errorElement, which hook returns the thrown error?

Best Practices & Pitfalls

βœ… Do

  • Prefer the data router (createBrowserRouter) for real apps β€” it unlocks loaders, actions, and error boundaries
  • Use nested routes + <Outlet /> to share layouts instead of repeating the shell on every page
  • Give every branch (at least the root) an errorElement so no crash reaches the user unstyled
  • Split large route trees into per-feature modules and lazy-load heavy screens
  • Keep an index route for defaults and a path: '*' for 404s

❌ Don't

  • Give child routes leading slashes ('/about') β€” they should be relative ('about')
  • Forget the <Outlet /> in a layout β€” the children simply won't appear
  • Duplicate the nav/footer in every page component when a layout route can hold it once
  • Render more than one <RouterProvider> β€” configure a single router at the top

⚠️ RouterProvider replaces BrowserRouter

When you adopt createBrowserRouter, you render <RouterProvider router={router} /> at the top β€” you do not also wrap in <BrowserRouter>. Mixing the two throws "You cannot render a <Router> inside another <Router>". Pick one style per app.

Summary

πŸŽ‰ Key Takeaways

  • Routes as data β€” an array of { path, element, children } objects β€” scales far better than scattered JSX
  • createBrowserRouter + <RouterProvider> unlocks loaders, actions, and errorElement boundaries
  • A parent route + <Outlet /> shares one layout across many child pages
  • index: true is the default child; path: '*' is your 404 catch-all
  • errorElement + useRouteError turn crashes into friendly, styled screens
  • Split routes into feature modules and lazy-load heavy screens to keep the bundle small

πŸ“š Additional Resources

πŸš€ What's Next?

Your route tree is defined and error-proofed. Now let's make moving through it feel great: the Navigation and Links lesson digs into <Link>, <NavLink> active styling, programmatic navigation, and accessible menus.

πŸŽ‰ Blueprint complete!

You can now describe an entire app's structure as one readable tree β€” layouts, defaults, errors, and all.