Layouts, outlets, and slots
Render persistent shells, nested route content, named regions, and intercepted destinations in core and React.
@cookbook/router-react renders the route match produced by @cookbook/router.
The core router owns matching, route normalization, slots, intercepts, params, search, hash parsing, redirects, rewrites, middleware, and lifecycle. The React package turns that resolved state into React views, outlets, slots, Suspense boundaries, and error boundaries.
The rendering model has three building blocks:
- A route view renders the current route segment.
- A layout view wraps route content and can render an
<Outlet />. - A slot renders a named secondary branch such as a sidebar, header, drawer, or modal.
Core and React rendering
The same route tree can be rendered by a custom core adapter or by the React integration.
Core rendering uses renderRouteMatch() and converts opaque RouteView values into adapter output.
import {
renderRouteMatch,
type RouteMatch,
} from '@cookbook/router';
interface Node {
readonly kind: string;
readonly routeId?: string;
readonly view?: unknown;
readonly children?: readonly Node[];
}
export function renderCoreTree(
match: RouteMatch | null,
): Node {
return renderRouteMatch(match, {
fallback: { kind: 'not-found' },
renderView(view, context) {
return {
kind: 'route',
routeId: context.match.id,
view,
children: [
context.outlet,
...Object.values(context.slots),
],
};
},
renderLayout(view, context) {
return {
kind: 'layout',
routeId: context.ownerRouteId,
view,
children: [
context.outlet,
...Object.values(context.slots),
],
};
},
renderEmpty(context) {
return { kind: context.reason };
},
});
}There is no <Outlet /> component in core. context.outlet and context.slots are the adapter-level equivalents.
React turns the same match into components, outlets, slots, boundaries, and hooks.
import {
Outlet,
Slot,
} from '@cookbook/router-react';
export function DashboardLayout() {
return (
<section>
<DashboardNavigation />
<main>
<Outlet />
</main>
<aside>
<Slot name="sidebar" />
</aside>
</section>
);
}<Outlet /> renders the next primary branch. <Slot /> renders a named secondary branch owned by the current layout route.
Mental model
A matched branch is rendered from parent to leaf.
app
dashboard
dashboard.reportA layout wraps the content below it:
function DashboardLayout() {
return (
<section>
<DashboardNavigation />
<Outlet />
</section>
);
}<Outlet /> renders the next primary child branch.
<Slot /> renders named content owned by the nearest active layout context.
function DashboardLayout() {
return (
<section>
<DashboardNavigation />
<main>
<Outlet />
</main>
<aside>
<Slot name="sidebar" />
</aside>
<Slot name="modal" />
</section>
);
}The filesystem does not decide this structure. The route tree does.
Route views
A route view renders when its route appears in the active branch:
function DashboardHome() {
return <h1>Dashboard</h1>;
}const routes = defineRoutes([
{
id: 'dashboard',
path: '/dashboard',
view: DashboardHome,
},
] as const);A route view can render an outlet when it also has children:
function DocumentsPage() {
return (
<section>
<h1>Documents</h1>
<Outlet />
</section>
);
}{
id: 'documents',
path: '/documents',
view: DocumentsPage,
children: [
{
id: 'documents.index',
index: true,
view: DocumentList,
},
{
id: 'documents.details',
path: '{documentId:slug}',
view: DocumentDetails,
},
],
}For /documents/handbook, DocumentsPage renders and its <Outlet /> renders DocumentDetails.
Layout views
A layout view is a persistent shell around route content.
function AppLayout() {
return (
<section>
<AppHeader />
<main>
<Outlet />
</main>
</section>
);
}const routes = defineRoutes([
{
id: 'app',
path: '/',
layout: {
view: AppLayout,
},
children: [
{
id: 'overview',
path: 'overview',
view: OverviewPage,
},
{
id: 'reports',
path: 'reports',
view: ReportsPage,
},
],
},
] as const);Navigating between /overview and /reports keeps AppLayout as the shell and swaps the content rendered through <Outlet />.
Use a layout when the parent owns persistent UI:
- Navigation
- Page shell
- Header or footer
- Sidebars
- Shared loading or error boundaries
- Named slots
Route view and layout view together
A route may have both view and layout.view.
{
id: 'dashboard',
path: '/dashboard',
view: DashboardPage,
layout: {
view: DashboardLayout,
},
children: [
{
id: 'dashboard.report',
path: 'reports/{reportId}',
view: ReportPage,
},
],
}In that case:
- The child branch is prepared.
- The route view receives that child branch as its outlet.
- The layout wraps the route view.
This is useful when the route has its own page component and also owns a shell around that page and its descendants.
Most applications use either a route view or a layout view at a given level. Use both only when the ownership is deliberate.
Outlet
Outlet renders the next primary child branch from the current route render context:
import { Outlet } from '@cookbook/router-react';
function RootLayout() {
return (
<main>
<Outlet />
</main>
);
}When no child branch exists, <Outlet /> renders nothing.
You can pass explicit children to override the router-provided outlet:
<Outlet>
<CustomChild />
</Outlet>That is useful for custom shells, but normal route rendering should rely on the router-provided outlet.
Outlet context
Outlet can provide context to descendants rendered through that outlet:
function DashboardLayout() {
const user = useCurrentUser();
return (
<section>
<Outlet context={{ user }} />
</section>
);
}Read it with useOutletContext():
import { useOutletContext } from '@cookbook/router-react';
interface DashboardContext {
readonly user: User;
}
function DashboardHome() {
const { user } = useOutletContext<DashboardContext>();
return <h1>{user.name}</h1>;
}The nearest rendered <Outlet /> or <Slot /> wins.
Context is intentionally local. It is not URL state, not global state, and not a replacement for params, search, or hash.
Use strict: true when missing context should be an error:
const context = useOutletContext<DashboardContext>({
strict: true,
});Generated route-ID overloads exist, but the current generator emits {} for outlet-context entries. Use the generic form for application-owned context shapes.
Slots
Named slots
Slots let a layout render named secondary regions.
Declare slots on a layout:
function DashboardLayout() {
return (
<section>
<DashboardHeader />
<main>
<Outlet />
</main>
<aside>
<Slot name="sidebar" />
</aside>
<Slot name="modal" />
</section>
);
}const routes = defineRoutes([
{
id: 'dashboard',
path: '/dashboard',
layout: {
view: DashboardLayout,
slots: {
sidebar: SidebarFallback,
modal: true,
},
},
children: [
{
id: 'dashboard.index',
index: true,
view: DashboardHome,
},
],
},
] as const);sidebar: SidebarFallback declares a slot with fallback content.
modal: true declares an enabled slot with no fallback content.
A layout must render the slot explicitly:
<Slot name="sidebar" />Declaring the slot makes it available. Rendering <Slot /> chooses where it appears.
Slot configuration forms
A slot can be declared with a view shorthand:
layout: {
view: DashboardLayout,
slots: {
sidebar: SidebarFallback,
},
}or with an object:
layout: {
view: DashboardLayout,
slots: {
sidebar: {
view: SidebarFallback,
meta: {
chrome: true,
},
routes: [
{
id: 'dashboard.sidebar.activity',
path: 'activity',
view: ActivitySidebar,
},
],
},
},
}Supported object keys are:
view
meta
routesThere is no fallback key. The slot fallback view is declared with view.
Slot routes
A slot can have its own route tree.
const routes = defineRoutes([
{
id: 'dashboard',
path: '/dashboard',
layout: {
view: DashboardLayout,
slots: {
sidebar: {
view: DashboardSidebarHome,
routes: [
{
id: 'dashboard.sidebar.reports',
path: 'reports',
view: ReportsSidebar,
},
],
},
},
},
children: [
{
id: 'dashboard.index',
index: true,
view: DashboardHome,
},
{
id: 'dashboard.reports',
path: 'reports',
view: ReportsPage,
},
],
},
] as const);For /dashboard, the sidebar renders DashboardSidebarHome.
For /dashboard/reports, the main outlet renders ReportsPage and the sidebar renders ReportsSidebar.
The main route branch and the slot route branch are matched from the same active pathname. The slot does not own a separate browser URL.
Slot context
Slot can provide outlet context to the content it renders:
<Slot
name="sidebar"
context={{
source: 'dashboard',
}}
/>Slot content reads it with useOutletContext():
interface SidebarContext {
readonly source: 'dashboard';
}
function ReportsSidebar() {
const context = useOutletContext<SidebarContext>();
return <p>{context.source}</p>;
}Slot context is local to that rendered slot subtree. It does not leak into the main outlet.
Slot overrides
Descendants can override an ancestor slot declaration while staying in the same layout shell:
{
id: 'dashboard',
path: '/dashboard',
layout: {
view: DashboardLayout,
slots: {
sidebar: DashboardSidebar,
},
},
children: [
{
id: 'dashboard.settings',
path: 'settings',
layout: {
slots: {
sidebar: SettingsSidebar,
},
},
view: SettingsPage,
},
],
}When /dashboard/settings is active, the same <Slot name="sidebar" /> rendered by DashboardLayout receives SettingsSidebar.
A descendant that declares its own layout.view owns a new layout scope. Slot ownership does not silently cross into unrelated layout shells.
Empty and disabled slots
A slot can render nothing.
layout: {
view: DashboardLayout,
slots: {
modal: true,
},
}<Slot name="modal" />If no fallback view, slot route, or intercept is active, the slot renders null.
Use true when the slot should exist as a target for intercepts or descendant overrides but should not render default content.
Slot error isolation
By default, a slot render error participates in the normal route/provider error-boundary chain.
Pass errorFallback to isolate the error to that slot:
<Slot
name="modal"
errorFallback={ModalError}
/>The fallback receives:
interface SlotErrorFallbackProps {
readonly error: unknown;
readonly reset: () => void;
}Pass null when the slot should catch the error and render nothing:
<Slot
name="notifications"
errorFallback={null}
/>Omitting the prop is different:
<Slot name="notifications" />Without errorFallback, the slot error is allowed to bubble to the nearest route, layout, or provider error fallback.
Layout fallbacks and slots
Layout fallbacks belong to the main outlet branch.
They are not automatically inherited by named slot route trees.
layout: {
view: DashboardLayout,
loading: DashboardLoading,
error: DashboardError,
slots: {
sidebar: {
routes: [
{
id: 'dashboard.sidebar.reports',
path: 'reports',
view: ReportsSidebar,
},
],
},
},
}DashboardLoading and DashboardError protect the main outlet rendered by <Outlet />.
The sidebar uses its own route or layout fallbacks, slot-local error isolation, or provider defaults.
There is no <Slot loadingFallback> prop.
See Loading and errors for the complete fallback precedence model.
Intercepts in slots
An intercept renders a destination route through a slot while preserving the source branch.
const routes = defineRoutes([
{
id: 'blog',
path: '/blog',
layout: {
view: BlogLayout,
slots: {
modal: true,
},
},
intercepts: {
modal: {
to: 'blog.posts.show',
view: BlogPostModal,
},
},
children: [
{
id: 'blog.index',
index: true,
view: BlogIndex,
},
],
},
{
id: 'blog.posts.show',
path: '/blog/{slug}',
view: BlogPostPage,
},
] as const);The layout renders the slot:
function BlogLayout() {
return (
<>
<Outlet />
<Slot name="modal" />
</>
);
}When the active source route is blog and the user navigates to blog.posts.show, the router can keep the blog branch rendered and show BlogPostModal in the modal slot.
<Link
route="blog.posts.show"
params={{ slug }}
>
Read in modal
</Link>Configured intercepts are automatic when the active source branch declares a matching intercept.
Use intercept={false} to bypass a configured intercept for one navigation:
<Link
route="blog.posts.show"
params={{ slug }}
intercept={false}
>
Open full page
</Link>Use an object form for an inline intercept:
<Link
route="blog.posts.show"
params={{ slug }}
intercept={{
slot: 'modal',
view: BlogPostPreview,
}}
>
Preview
</Link>A string intercept="modal" only selects a configured slot. It does not provide a render view by itself.
Intercept render context
Intercept views receive the destination route render context.
Inside an intercepted blog.posts.show view, route hooks read the destination state:
function BlogPostModal() {
const params = useParams('blog.posts.show');
return (
<Modal>
Post: {params.slug}
</Modal>
);
}A link can also pass intercept context:
<Link
route="blog.posts.show"
params={{ slug }}
intercept={{
slot: 'modal',
view: BlogPostPreview,
}}
context={{
source: 'card',
}}
>
Preview
</Link>The intercept view can read that value with useOutletContext():
const context = useOutletContext<{ source: string }>();Provider fallbacks
RouterProvider has three fallback props:
<RouterProvider
router={router}
fallback={<NotFoundPage />}
loadingFallback={<ApplicationLoading />}
errorFallback={ApplicationError}
/>They do different jobs:
| Prop | Purpose |
|---|---|
fallback | Rendered when no route matches |
loadingFallback | Default Suspense fallback when no route or layout fallback owns pending content |
errorFallback | Default error fallback when no route, layout, or slot fallback owns the failure |
fallback is not an error fallback.
Use a wildcard route when not-found should participate in layouts, metadata, middleware, or lifecycle:
{
id: 'not-found',
path: '/{*path}',
view: NotFoundPage,
}Route-state errors
Provider and route error fallbacks can render router-state errors as well as React render errors.
Examples include:
- Strict URL-state failures
- Middleware errors
- Lifecycle errors
- Returned middleware
Responsevalues
When no active match exists, only the provider errorFallback can render the error. Otherwise, the selected route, layout, or provider error fallback renders it.
For router-state errors, reset() is not a universal retry. Use navigation or router.refresh() when the transition should be attempted again.
Rendering without a view
A route with no view can still participate in rendering.
{
id: 'documents',
path: '/documents',
children: [
{
id: 'documents.index',
index: true,
view: DocumentList,
},
],
}When documents.index is active, the parent route passes through to the child branch.
This is useful for structural routes that affect matching, metadata, params, middleware, lifecycle, or generated contracts without adding React UI.
When no match exists, RouterProvider.fallback renders.
Hooks in render context
Route and slot views receive a local render context.
function DocumentDetails() {
const params = useParams('documents.details');
const meta = useRouteMeta('documents.details');
return <h1>{params.documentId}</h1>;
}useParams() without a route ID prefers the local route render context when one exists. Passing a route ID is clearer when a component can render in more than one branch or slot.
For URL state and hook details, see Typed contracts.
What belongs elsewhere
This page explains the rendering model.
Use the dedicated pages for adjacent topics:
| Topic | Page |
|---|---|
| Link props, active links, and prefetch | React integration |
| React hooks | React hooks |
| Loading and error fallback precedence | Loading and errors |
| Intercept rules | Interception |
| Navigation blockers | Redirects, rewrites, and cancellation |
Where this bites
A route view is not a layout shell
Use layout.view when descendants should render inside persistent UI. A route view is local route content.
A parent route fallback does not protect every child
Route loading and error fallbacks are leaf-local. Put shared fallback behavior on layout.loading and layout.error.
A layout fallback does not own slots
Named slots are separate branches. Use slot route fallbacks or <Slot errorFallback> when a slot needs independent ownership.
A slot declaration does not render anything by itself
The layout view must call <Slot name="..." />.
A slot with true renders nothing by default
It only enables the slot for intercepts or descendant overrides.
intercept="modal" is not an inline view
String intercepts select configured intercepts. Inline intercepts must provide { slot, view }.
Outlet context is local
The nearest <Outlet /> or <Slot /> provides the value. It is not global application state.
useHash() is not a public hook
Use useHashParams().
Provider middleware can miss startup
When middleware is passed to RouterProvider after the router was already started, it only affects future transitions. Register startup-critical middleware before router.start() or let the provider auto-start the router.