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.
  • children as 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.
Who decides what fills the hole: slot versus outlet Two diagrams. With a slot, the consuming component supplies the content, so the relationship is decided at the call site. With an outlet, the router supplies the content based on the matched URL, so the layout never names its children. Slot — the caller fills it consumer supplies the content layout component <slot /> Relationship fixed at the call site. Outlet — the router fills it URL → route chain /settings/billing layout component <Outlet /> billing panel chosen by the URL The layout never names its children.
A slot is filled by whoever uses the component; an outlet is filled by whichever route the URL selected.

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 route chain rendering into nested outlets A three-level chain: the app shell's outlet holds the settings layout, whose outlet holds the billing panel. Navigating to the profile panel replaces only the innermost outlet's contents. app shell header · theme · toasts outlet → settings layout sidebar · scroll position preserved outlet → billing panel the only level replaced on a sibling navigation /settings/billing chain: shell → settings → billing three levels, two outlets → /settings/profile shell and settings untouched only the inner outlet re-renders
Outlets nest: each level holds the next, and a sibling navigation only replaces the contents of the deepest one that differs.

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 useOutletContext and 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.
An outlet compared with the three patterns it resembles Four comparisons: a slot, a children prop, a portal and a conditional render. Each shares a surface similarity with an outlet but differs in who supplies the content or in whether the parent survives. What an outlet is not looks similar because differs because a slot both mark a hole in markup the caller fills it, not the router a children prop both receive a subtree supplied by the consumer, not the URL a portal both place content elsewhere a portal relocates; an outlet stays put a conditional render both can swap content a conditional remounts the parent
Only the outlet takes its content from the URL — and only the outlet guarantees the parent is not remounted.

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.