Nested Route Outlets Explained
After reading this you will be able to explain exactly what an outlet is, why it is filled by the router rather than by the parent component, and how to recognise the same contract in React Router, Vue Router, Angular and SvelteKit.
← Back to Nested & Layout Routing
Prerequisites
Core Concept
An outlet is a placeholder inside a parent route’s markup where the router renders whichever child route matched. It is not a slot, it is not a prop, and — this is the part that trips people up — the parent does not choose what goes in it. The parent declares where the child belongs; the URL decides what the child is.
That inversion is the whole design. If the parent picked its child, the parent would need to know every route beneath it, and adding a screen would mean editing the layout. With an outlet, the layout is written once against a hole, and new children are added purely by extending the route table. The layout never learns their names.
The mechanical consequence is the one that matters for behaviour: because the parent component is rendered by the level above it and only the outlet’s contents change, the parent is not re-created when the child changes. Its state survives, its DOM nodes are reused, its scroll offset is preserved. Outlets are how a router expresses persistence.
It is worth distinguishing an outlet from three things it resembles:
- A slot (Vue’s
<slot>, web components’<slot>) is filled by the caller — the component that uses this component decides the content. An outlet is filled by the router. childrenas a prop is the same relationship as a slot: the parent’s consumer supplies it.- A portal renders content somewhere else in the DOM tree entirely. An outlet renders in place; only the source of the content is unusual.
Implementation
Stripped of framework machinery, an outlet is a function the router passes into the parent’s render call.
// TypeScript 5.x — an outlet is a closure over the already-built child
interface RenderContext {
params: Record<string, string>;
/** Returns the rendered child for this level, or null at the leaf. */
outlet: () => Node | null;
}
const settingsLayout = (ctx: RenderContext): Node => {
const root = document.createElement("div");
root.className = "settings";
const sidebar = document.createElement("nav");
sidebar.className = "settings-nav";
sidebar.append(link("/settings/profile", "Profile"), link("/settings/billing", "Billing"));
const panel = document.createElement("section");
// The hole. The layout knows WHERE the child goes, never WHICH child it is.
const child = ctx.outlet();
if (child) panel.append(child);
root.append(sidebar, panel);
return root;
};
The router builds the child first and hands it down, which is why the parent can render it without knowing anything about it:
// TypeScript 5.x — fold the chain leaf-first so each parent gets a ready subtree
function render(chain: { render: (c: RenderContext) => Node; params: Record<string, string> }[]) {
let child: Node | null = null;
for (let i = chain.length - 1; i >= 0; i--) {
const built = child; // capture before reassignment
child = chain[i].render({ params: chain[i].params, outlet: () => built });
}
return child;
}
The same contract in four frameworks — note that only the spelling changes:
// React Router 6.22 — <Outlet /> reads the matched child from context
import { Outlet } from "react-router-dom";
export function SettingsLayout() {
return (
<div className="settings">
<SettingsNav />
<section>
<Outlet />
</section>
</div>
);
}
<!-- Vue Router 4.3 — <router-view> is the outlet -->
<template>
<div class="settings">
<SettingsNav />
<section>
<router-view />
</section>
</div>
</template>
// Angular 17 — <router-outlet> in the template, standalone component
@Component({
standalone: true,
imports: [RouterOutlet, SettingsNav],
template: `
<div class="settings">
<app-settings-nav />
<section><router-outlet /></section>
</div>
`,
})
export class SettingsLayout {}
<!-- SvelteKit 2 — +layout.svelte receives the child implicitly -->
<script lang="ts">
let { children } = $props();
</script>
<div class="settings">
<SettingsNav />
<section>{@render children()}</section>
</div>
Named outlets are the one meaningful extension. When a layout has two independent holes — a main panel and a detail drawer, say — the router needs to know which child goes where:
// React Router 6.22 — two outlets addressed by name
<div className="mail">
<section className="list"><Outlet /></section>
<aside className="preview"><Outlet context="preview" /></aside>
</div>
Angular spells this <router-outlet name="preview"> with a matching outlet: "preview" in the route config; Vue uses <router-view name="preview"> with a components map instead of a single component.
One property of outlets is easy to miss and worth stating explicitly: the child is built before the parent renders. That ordering is forced by the composition — the parent needs a finished subtree to place in its hole — and it has a practical consequence. A layout cannot inspect its child, cannot ask what route filled it, and cannot conditionally style itself based on which screen is showing without being told separately. Teams that want a layout to react to its child usually end up reading the current pathname from the router instead, which is the correct approach: the URL is the shared source of truth, and reaching into the rendered child would reintroduce exactly the coupling the outlet removed.
Verification
The check that an outlet is doing its job is not that the child appears — it is that the parent does not re-appear.
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("the outlet's parent survives a sibling navigation", async ({ page }) => {
await page.goto("/settings/profile");
// Give the layout an identity that changes on mount.
const id = await page.locator("[data-layout='settings']").getAttribute("data-mount-id");
await page.getByRole("link", { name: "Billing" }).click();
await expect(page.locator("[data-panel='billing']")).toBeVisible();
expect(await page.locator("[data-layout='settings']").getAttribute("data-mount-id")).toBe(id);
});
If you have no framework devtools handy, the DOM inspector works: expand to the layout node, navigate between siblings, and watch whether the node itself flashes as replaced or stays put while its child subtree changes.
Gotchas
- A parent that renders no outlet renders no children. The route matches, the loader runs, and nothing appears — the most confusing possible symptom because everything else looks correct.
- An outlet with no matching child renders nothing. Visiting the parent path directly needs an index route to fill the hole.
- Two unnamed outlets in one layout are ambiguous. The router fills the first and leaves the second empty, or fills both identically, depending on implementation. Name them.
- Conditionally rendering the outlet destroys persistence. Wrapping it in a condition that flips during navigation unmounts and remounts the child, which is exactly what nesting was meant to avoid.
- Outlet context is not a general state channel. React Router’s
useOutletContextand Vue’s slot props are convenient, but treating them as a store makes the layout and its children mutually dependent — see sharing layout state across nested routes.
FAQ
Why does the router fill the outlet instead of the parent? So the layout does not have to know its children. Adding a screen becomes a route-table change rather than an edit to every layout above it.
Can a component have more than one outlet? Yes, if they are named. Named outlets let one URL drive two independent regions, such as a list and a preview pane.
Does an outlet create a DOM element? No. It is a rendering position, not a wrapper. If you need a container with a class or a landmark role, add it yourself around the outlet.
What renders inside the outlet at the leaf? Nothing — the deepest matched route has no child, so its outlet() returns null. Layouts should handle that case rather than assuming a child exists.
Is an outlet the same as a <router-view>? Yes. Vue’s <router-view>, Angular’s <router-outlet>, React Router’s <Outlet /> and SvelteKit’s layout children are the same contract with different names.
Related
- Nested & Layout Routing — the route tree and match chain that outlets render.
- Index Routes vs Layout Routes — what fills the outlet when the URL stops at the parent.
- Sharing Layout State Across Nested Routes — passing data through an outlet without coupling the layout to its children.