Share layouts across pages with nested routes and Outlet, and protect routes behind authentication.

Bishal Biswas
Self Learner
The repetition problem
function Home() {
return (
<div>
<Header />
<Nav />
<main>Home content</main>
<Footer />
</div>
);
}
function About() {
return (
<div>
<Header />
<Nav />
<main>About content</main>
<Footer />
</div>
);
}
Every page repeats the shell. Worse, the header and nav remount on every navigation, so any state in them is lost and animations restart.
Nested routes
Define a parent route that renders the shell, with children rendering inside it.
import { createBrowserRouter, Outlet } from 'react-router-dom';
function RootLayout() {
return (
<div>
<Header />
<Nav />
<main>
<Outlet />
</main>
<Footer />
</div>
);
}
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{ index: true, element: <Home /> },
{ path: 'about', element: <About /> },
{ path: 'products', element: <Products /> }
]
}
]);
<Outlet /> is the hole where the matched child renders. The header, nav and footer stay mounted across navigations, keeping their state.
Note index: true, which means “this is the child for the parent’s own path”. It is how / gets a component.
Note also that child paths have no leading slash. path: 'about' becomes /about because it is relative to the parent.
Layouts within layouts
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{ index: true, element: <Home /> },
{
path: 'dashboard',
element: <DashboardLayout />,
children: [
{ index: true, element: <Overview /> },
{ path: 'settings', element: <Settings /> },
{ path: 'billing', element: <Billing /> }
]
}
]
}
]);
function DashboardLayout() {
return (
<div className="dashboard">
<DashboardSidebar />
<div className="dashboard-content">
<Outlet />
</div>
</div>
);
}
/dashboard/settings renders RootLayout, then DashboardLayout inside its outlet, then Settings inside that one. Each level adds its own chrome.
Layout routes without a path
Sometimes you want a shared layout without adding a URL segment.
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{ index: true, element: <Home /> },
{
element: <AuthLayout />,
children: [
{ path: 'login', element: <Login /> },
{ path: 'register', element: <Register /> }
]
}
]
}
]);
AuthLayout has no path, so it wraps /login and /register without becoming /auth/login. This is called a pathless layout route.
Protecting routes
import { Navigate, useLocation } from 'react-router-dom';
function RequireAuth() {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <Spinner />;
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
{
element: <RequireAuth />,
children: [
{ path: 'dashboard', element: <Dashboard /> },
{ path: 'settings', element: <Settings /> }
]
}
One guard protects every child. Three things make it work properly:
- The loading check first. Without it you redirect to login while auth is still being determined, and logged in users get bounced on every refresh.
state={{ from: location }}remembers where they were going.replaceso the back button does not return to the protected page.
Then send them back after login:
function Login() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname ?? '/dashboard';
async function handleSubmit(event) {
event.preventDefault();
await login();
navigate(from, { replace: true });
}
}
This is not security. Anyone can bypass a client side guard. The server must check permissions on every request. The guard is for user experience.
Role based access
function RequireRole({ role }) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
if (!user.roles.includes(role)) return <Navigate to="/forbidden" replace />;
return <Outlet />;
}
{
element: <RequireRole role="admin" />,
children: [{ path: 'admin', element: <AdminPanel /> }]
}
Error elements
{
path: '/',
element: <RootLayout />,
errorElement: <RootError />,
children: [
{ path: 'products/:id', element: <ProductDetail />, errorElement: <ProductError /> }
]
}
errorElement catches errors from that route and its children. The nearest one wins, so a broken product page shows a product error inside the layout, rather than replacing the whole page.
Read the error with useRouteError:
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
function RootError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <p>{error.status} {error.statusText}</p>;
}
return <p>Something went wrong.</p>;
}
Relative links
Inside a nested route, links are relative to the current route.
function DashboardNav() {
return (
<nav>
<Link to="settings">Settings</Link>
<Link to="/dashboard/settings">Settings</Link>
<Link to="..">Up one level</Link>
</nav>
);
}
Relative links keep working if you later move the whole section to a different path.
Quick recap
- A parent route renders the layout,
<Outlet />marks where children go. - Layout components stay mounted across navigations, so their state survives.
index: trueis the child for the parent’s own path.- Child paths are relative, with no leading slash.
- A pathless layout route shares chrome without adding a URL segment.
- A guard route with
<Outlet />protects every child at once. - Check loading before redirecting, and remember where the user was going.
- Client side guards are user experience, never security.
import {
createBrowserRouter, Outlet, Navigate, Link,
useLocation, useNavigate, useRouteError
} from 'react-router-dom';
// A layout renders the shell once. Outlet is the hole the matched child
// renders into. The header and nav stay MOUNTED across navigations, so
// their state and animations survive.
function RootLayout() {
return (
<div>
<Header />
<main><Outlet /></main>
<Footer />
</div>
);
}
function DashboardLayout() {
return (
<div className="dashboard">
<aside><Link to="settings">Settings</Link></aside>
<Outlet />
</div>
);
}
// One guard protects every child route beneath it
function RequireAuth() {
const { user, isLoading } = useAuth();
const location = useLocation();
// Check loading FIRST, or logged in users get bounced on every refresh
if (isLoading) return <p>Checking your session...</p>;
if (!user) {
// Remember where they were going, and replace so Back does not
// return them to the protected page
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
function Login() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname ?? '/dashboard';
async function handleSubmit(event) {
event.preventDefault();
await login();
navigate(from, { replace: true }); // back where they wanted to go
}
return <form onSubmit={handleSubmit}><button>Log in</button></form>;
}
function RootError() {
const error = useRouteError();
return <p role="alert">Something went wrong: {error?.statusText ?? 'unknown'}</p>;
}
export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <RootError />,
children: [
{ index: true, element: <Home /> }, // the child for '/'
{ path: 'about', element: <About /> }, // relative, no leading /
{
// A pathless layout route: shared chrome, no extra URL segment,
// so these stay at /login and /register
element: <AuthLayout />,
children: [
{ path: 'login', element: <Login /> },
{ path: 'register', element: <Register /> }
]
},
{
element: <RequireAuth />, // guards everything below
children: [
{
path: 'dashboard',
element: <DashboardLayout />,
children: [
{ index: true, element: <Overview /> },
{ path: 'settings', element: <Settings /> }
]
}
]
},
{ path: '*', element: <NotFound /> }
]
}
]);
// REMEMBER: a client side guard is user experience, not security.
// The server must check permissions on every single request.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready