πΊοΈ 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-basedcreateBrowserRouterstyle - Define a route tree as an array of objects with
path,element, andchildren - Build shared layouts with a parent route and an
<Outlet /> - Use
indexroutes and a catch-all to handle defaults and 404s - Attach an
errorElementboundary and read errors withuseRouteError - 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.
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 elements | Routes written as plain objects |
| Great for small apps & quick demos | Great for real apps that scale |
| No data loaders / actions | Unlocks loaders, actions, errorElement |
| Config lives inside a component | Config 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.
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.
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
errorElementso no crash reaches the user unstyled - Split large route trees into per-feature modules and lazy-load heavy screens
- Keep an
indexroute for defaults and apath: '*'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, anderrorElementboundaries- A parent route +
<Outlet />shares one layout across many child pages index: trueis the default child;path: '*'is your 404 catch-allerrorElement+useRouteErrorturn crashes into friendly, styled screens- Split routes into feature modules and lazy-load heavy screens to keep the bundle small
π Additional Resources
- React Router β Data routing & createBrowserRouter
- React Router β Nested routes & Outlet
- React Router β Error boundaries
π 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.