Every multi-page React application has layouts: a navigation bar at the top, a sidebar, a footer, an authenticated wrapper. The naive approach repeats these in every page component. React Router’s nested routes provide a better model: define the layout once, render child routes inside it, and let the router handle the composition.

The problem with the flat approach

Without nested routes, you end up repeating layout components in every page:

function Dashboard() {
  return (
    <>
      <NavBar />
      <Sidebar />
      <main>Dashboard content</main>
      <Footer />
    </>
  );
}

function Settings() {
  return (
    <>
      <NavBar />
      <Sidebar />
      <main>Settings content</main>
      <Footer />
    </>
  );
}

function Profile() {
  return (
    <>
      <NavBar />
      <Sidebar />
      <main>Profile content</main>
      <Footer />
    </>
  );
}

Every new page adds the same boilerplate. Changing the layout means touching every page.

The nested route pattern

React Router v6 uses <Outlet /> as a placeholder for child routes. The parent layout renders once and the child routes render in the Outlet’s position.

// AppLayout.jsx
import { Outlet } from 'react-router-dom';

function AppLayout() {
  return (
    <>
      <NavBar />
      <Sidebar />
      <main>
        <Outlet /> {/* Child routes render here */}
      </main>
      <Footer />
    </>
  );
}

Define the route hierarchy in your router configuration:

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

const router = createBrowserRouter([
  {
    path: '/',
    element: <AppLayout />,
    children: [
      { index: true, element: <Dashboard /> },
      { path: 'settings', element: <Settings /> },
      { path: 'profile', element: <Profile /> },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

Now Dashboard, Settings, and Profile are simple components that only render their own content:

function Dashboard() {
  return <div>Dashboard content</div>;
}

function Settings() {
  return <div>Settings content</div>;
}

The layout renders once. The Outlet swaps the child component as the user navigates. NavBar, Sidebar, and Footer are not re-rendered on route changes within this layout.

Stacking multiple layouts

Nesting can go multiple levels deep. A common pattern is one layout for the public site and another for authenticated users:

const router = createBrowserRouter([
  {
    path: '/',
    element: <PublicLayout />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'about', element: <AboutPage /> },
      { path: 'login', element: <LoginPage /> },
    ],
  },
  {
    path: '/app',
    element: <AuthenticatedLayout />, // Checks auth, redirects if not logged in
    children: [
      { index: true, element: <Dashboard /> },
      { path: 'settings', element: <Settings /> },
      {
        path: 'projects',
        element: <ProjectsLayout />, // Another layout level with its own nav
        children: [
          { index: true, element: <ProjectList /> },
          { path: ':projectId', element: <ProjectDetail /> },
        ],
      },
    ],
  },
]);

Each layout in the chain renders an <Outlet />. The router resolves the full path and renders the appropriate chain of layouts and child components.

The authenticated layout pattern

A common use of nested routes is protecting a section of the app:

function AuthenticatedLayout() {
  const { user, isLoading } = useAuth();

  if (isLoading) return <LoadingSpinner />;
  if (!user) return <Navigate to="/login" replace />;

  return (
    <div className="app-shell">
      <NavBar user={user} />
      <main>
        <Outlet />
      </main>
    </div>
  );
}

Any route nested under this layout is automatically protected. No need to add auth checks to individual page components.

Passing data from layout to child routes

Use <Outlet /> with a context prop to pass values from the layout to child routes:

function ProjectsLayout() {
  const { projectId } = useParams();
  const { data: project } = useFetch(`/api/projects/${projectId}`);

  return (
    <div>
      <ProjectHeader project={project} />
      <Outlet context={{ project }} /> {/* Pass project to all children */}
    </div>
  );
}

// In a child route
import { useOutletContext } from 'react-router-dom';

function ProjectDetail() {
  const { project } = useOutletContext();
  return <div>{project?.name}</div>;
}

Using JSX-based route definitions

The object-based createBrowserRouter syntax is the current recommended approach, but if you prefer JSX, nested routes work the same way:

import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<AppLayout />}>
          <Route index element={<Dashboard />} />
          <Route path="settings" element={<Settings />} />
          <Route path="profile" element={<Profile />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

The <Outlet /> in AppLayout renders whichever child route matches the current path.

The result is that layouts are defined exactly once. Adding a new page means creating a component and a route definition. No repeated wrapper code, no risk of inconsistencies between pages.