Nested routes let you build complex page layouts where parts of the UI persist across navigation while other parts change. Think of a dashboard with a sidebar that stays in place while the main content area updates—that's nested routing in action.
Consider a typical dashboard application. You have a header, a sidebar, and a main content area. When users navigate between different dashboard pages, the header and sidebar should remain visible while only the main content changes.
Without nested routes, you'd have two options:
Nested routes solve this elegantly by letting you compose layouts hierarchically. Parent routes define the persistent UI, and child routes fill in the changing parts.
The key to nested routing is the <Outlet> component. It acts as a placeholder in a parent route's component where child routes will be rendered.
import { Outlet } from "@funstack/router";
function DashboardLayout() {
return (
<div className="dashboard">
<aside className="sidebar">
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/analytics">Analytics</a>
<a href="/dashboard/settings">Settings</a>
</nav>
</aside>
<main className="content">
{/* Child routes render here */}
<Outlet />
</main>
</div>
);
}When you navigate to /dashboard/analytics, the DashboardLayout component renders the sidebar, and the <Outlet> renders the Analytics page component.
Nested routes are defined using the children property in your route definitions. Each child route's path is relative to its parent.
import { route } from "@funstack/router";
const routes = [
route({
path: "/dashboard",
component: DashboardLayout,
children: [
// Matches "/dashboard" exactly
route({
path: "/",
component: DashboardOverview,
}),
// Matches "/dashboard/analytics"
route({
path: "/analytics",
component: AnalyticsPage,
}),
// Matches "/dashboard/settings"
route({
path: "/settings",
component: SettingsPage,
}),
],
}),
];Notice how child paths start with / but are relative to the parent. The path /analytics under a parent with path /dashboard matches the full URL /dashboard/analytics.
Understanding how routes match URLs is crucial for nested routing. The router uses different matching strategies depending on whether a route has children.
Routes with children use prefix matching. They match any URL that starts with their path, allowing child routes to match the remaining portion.
route({
path: "/dashboard", // Matches "/dashboard", "/dashboard/settings", etc.
component: DashboardLayout,
children: [
// Children handle the rest of the URL
],
})When you navigate to /dashboard/settings/profile, the parent route /dashboard matches and consumes that portion of the URL. The remaining /settings/profile is then matched against its children.
Routes without children (leaf routes) use exact matching by default. They only match when the URL exactly matches their full path.
route({
path: "/dashboard",
component: DashboardLayout,
children: [
route({
path: "/settings", // Only matches "/dashboard/settings" exactly
component: SettingsPage,
}),
],
})With this configuration, /dashboard/settings matches, but /dashboard/settings/advanced does not—there's no child route to handle /advanced.
A common pattern is using path: "/" as an index route. This matches when the parent's path is matched exactly with no additional segments.
route({
path: "/dashboard",
component: DashboardLayout,
children: [
route({
path: "/", // Matches "/dashboard" exactly
component: DashboardHome,
}),
route({
path: "/settings", // Matches "/dashboard/settings"
component: SettingsPage,
}),
],
})Here, navigating to /dashboard renders DashboardLayout with DashboardHome in its outlet. Navigating to /dashboard/settings renders DashboardLayout with SettingsPage instead.
Sometimes you want a parent route to only match its exact path, not act as a prefix. Use the exact option:
route({
path: "/blog",
exact: true, // Only matches "/blog", not "/blog/post-1"
component: BlogIndex,
children: [
route({
path: "/:slug", // This won't match because parent requires exact
component: BlogPost,
}),
],
})With exact: true, the route only matches when the URL is exactly /blog. URLs like /blog/post-1 won't match this route at all. This is rarely needed but useful when you want a route to behave as a leaf even though it has children defined.
By default, parent routes require at least one child route to match. If no children match, the parent doesn't match either—allowing other routes (like a catch-all) to handle the URL instead.
const routes = [
route({
path: "/dashboard",
component: DashboardLayout,
children: [
route({ path: "/", component: DashboardHome }),
route({ path: "/settings", component: SettingsPage }),
],
}),
route({
path: "/*", // Catch-all for unmatched routes
component: NotFoundPage,
}),
];
// /dashboard → matches DashboardLayout + DashboardHome
// /dashboard/settings → matches DashboardLayout + SettingsPage
// /dashboard/unknown → matches NotFoundPage (not DashboardLayout)This behavior ensures that catch-all routes work intuitively. Without it, /dashboard/unknown would match the dashboard layout with an empty outlet, which is usually not desired.
If you want a parent route to match even when no children match, set requireChildren: false. The <Outlet> will render null in this case.
route({
path: "/files",
component: FileExplorer,
requireChildren: false, // Match even without child matches
children: [
route({ path: "/:fileId", component: FileDetails }),
],
});
// /files → matches FileExplorer (outlet is null)
// /files/123 → matches FileExplorer + FileDetailsYou can nest routes as deeply as your application requires. Each level can have its own layout component with an <Outlet>.
const routes = [
route({
path: "/",
component: RootLayout, // Header, footer
children: [
route({
path: "/",
component: HomePage,
}),
route({
path: "/dashboard",
component: DashboardLayout, // Adds sidebar
children: [
route({
path: "/",
component: DashboardHome,
}),
route({
path: "/settings",
component: SettingsLayout, // Adds settings tabs
children: [
route({
path: "/",
component: GeneralSettings,
}),
route({
path: "/security",
component: SecuritySettings,
}),
route({
path: "/notifications",
component: NotificationSettings,
}),
],
}),
],
}),
],
}),
];When you navigate to /dashboard/settings/security, the rendering stack looks like:
RootLayout renders the header and footer with an <Outlet>DashboardLayout renders the sidebar with an <Outlet>SettingsLayout renders the settings tabs with an <Outlet>SecuritySettings renders the actual page contentParent routes can load data that child routes need. This is particularly useful for loading user information, permissions, or other shared data once at the parent level.
import { use, Suspense } from "react";
import { route, Outlet, useRouteData } from "@funstack/router";
// Define the parent route with a loader and child routes
const teamRoute = route({
id: "team",
path: "/teams/:teamId",
component: TeamLayout,
loader: async ({ params }) => {
const response = await fetch(`/api/teams/${params.teamId}`);
return response.json();
},
children: [
route({ path: "/", component: TeamOverview }),
route({ path: "/members", component: TeamMembers }),
route({ path: "/settings", component: TeamSettings }),
],
});
// Parent layout loads team data once, child routes render in <Outlet />
function TeamLayoutContent({
data,
}: {
data: Promise<{ name: string; members: string[] }>;
}) {
const team = use(data);
return (
<div>
<h1>{team.name}</h1>
<nav>
<a href="members">Members ({team.members.length})</a>
<a href="settings">Settings</a>
</nav>
<Outlet />
</div>
);
}
function TeamLayout(props: {
data: Promise<{ name: string; members: string[] }>;
}) {
return (
<Suspense fallback={<div>Loading team...</div>}>
<TeamLayoutContent {...props} />
</Suspense>
);
}Child routes can access the parent's loaded data using the useRouteData hook with the parent's route ID:
function TeamMembers() {
// Access parent route's data by route ID
const teamData = useRouteData(teamRoute);
const team = use(teamData);
return (
<ul>
{team.members.map((member) => (
<li key={member}>{member}</li>
))}
</ul>
);
}Sometimes you want to wrap a group of routes in a layout without adding a path segment. These are called pathless routes—routes that provide UI structure without affecting the URL.
const routes = [
route({
path: "/",
component: RootLayout,
children: [
route({ path: "/", component: HomePage }),
route({ path: "/about", component: AboutPage }),
// Pathless route - doesn't add to the URL
route({
component: AuthenticatedLayout, // No path property
children: [
route({ path: "/dashboard", component: Dashboard }),
route({ path: "/profile", component: Profile }),
route({ path: "/settings", component: Settings }),
],
}),
],
}),
];The AuthenticatedLayout component wraps /dashboard, /profile, and /settings without adding anything to their URLs. This is perfect for:
Pathless routes also play a key role in server-side rendering. During SSR, only pathless routes render (since no URL is available on the server), making them ideal for defining the app shell. See the Server-Side Rendering page for details.
Let's put it all together with a realistic example—a project management application with nested layouts.
import { Router, route, Outlet } from "@funstack/router";
import { Suspense } from "react";
// Root layout with app-wide header
function AppLayout() {
return (
<div className="app">
<header>
<h1>Project Manager</h1>
<nav>
<a href="/">Home</a>
<a href="/projects">Projects</a>
</nav>
</header>
<Outlet />
<footer>© 2024 Project Manager</footer>
</div>
);
}
// Projects section layout with project list sidebar
function ProjectsLayout() {
return (
<div className="projects-layout">
<aside className="project-list">
<h2>Your Projects</h2>
{/* Project list would be loaded here */}
</aside>
<main>
<Outlet />
</main>
</div>
);
}
// Individual project layout with project-specific navigation
function ProjectLayout({ params }: { params: { projectId: string } }) {
return (
<div className="project">
<nav className="project-nav">
<a href={`/projects/${params.projectId}`}>Overview</a>
<a href={`/projects/${params.projectId}/tasks`}>Tasks</a>
<a href={`/projects/${params.projectId}/team`}>Team</a>
</nav>
<Outlet />
</div>
);
}
// Route definitions
const routes = [
route({
path: "/",
component: AppLayout,
children: [
route({
path: "/",
component: HomePage,
}),
route({
path: "/projects",
component: ProjectsLayout,
children: [
route({
path: "/",
component: ProjectListPage,
}),
route({
path: "/:projectId",
component: ProjectLayout,
children: [
route({
path: "/",
component: ProjectOverview,
}),
route({
path: "/tasks",
component: ProjectTasks,
}),
route({
path: "/tasks/:taskId",
component: TaskDetail,
}),
route({
path: "/team",
component: ProjectTeam,
}),
],
}),
],
}),
],
}),
];
function App() {
return <Router routes={routes} />;
}With this structure:
/ shows AppLayout → HomePage/projects shows AppLayout → ProjectsLayout → ProjectListPage/projects/123 shows AppLayout → ProjectsLayout → ProjectLayout → ProjectOverview/projects/123/tasks/456 shows AppLayout → ProjectsLayout → ProjectLayout → TaskDetail (with both projectId: "123" and taskId: "456")<Outlet> to mark where child routes should render