App Router Hand-offs: Deep Dive #56
Principal React Architect
App Router hand-offs are how you keep navigation fast while your routing vocabulary stays mixed. The file router remains the public address layer, and the component router becomes the operational layer—but you still need a reliable way to hand off responsibility between segments without losing the story.
This post follows the repo plan’s “hand-off” theme and the global requirements:
- Always ground in:
- Add one extra doc link:
A hand-off is a contract boundary: a route segment gives another segment a stable state handle, a navigation intent, and an evidence trail. The receiving segment must not need to “re-infer” what happened.
The Hand-off Contract (Simple, Repeatable, Auditable)
We’ll enforce four hand-off invariants:
- One intent object travels between segments.
- One state handle is shared across the hand-off (global-local).
- One ledger records requested vs granted route decisions.
- One shell owns the conflict resolution policy.
This aligns with the core concepts posture that components host route decisions and events are transitions. See: "The Route-First Core Concept" and "Events Are Route Transitions".
Why Hand-offs Matter More Than “Routing Choice”
Most teams frame routing as “file router vs component router.” Our docs explicitly recommend using both. See: "Pages vs App Router (Use Both)".
That means “choosing a router” is not your problem. Your problem is:
- how does a file route arrival become a component route track decision,
- how do nested layouts keep shared state stable,
- and how do you prove what happened after the hand-off.
Worked Example: A Hand-off Orchestrator for /workbench
We’ll build a /workbench file route that hosts component tracks:
inbox,editor,preview,history.
The core artifact is a HandOffOrchestrator that:
- reads arrival intent from route hints,
- hands intent + state to the receiving track,
- and records every decision in a render-visible ledger.
Step 1: Define the Intent Shape (Wide, Derived, Stored)
We keep inputs wide (so routing never blocks) and store the derived shape in state so it’s reusable across segments.
import { createAssertion, useLiveState } from "react";
export const AsWorkbenchIntent = createAssertion("WorkbenchIntent", (value) => {
const v = value ?? {};
return {
track: v.track === "inbox" || v.track === "editor" || v.track === "preview" || v.track === "history" ? v.track : "inbox",
docId: typeof v.docId === "string" ? v.docId : "latest",
mode: typeof v.mode === "string" ? v.mode : "focus",
handoffId: typeof v.handoffId === "string" ? v.handoffId : `handoff:${Date.now()}`,
};
});
export function useHandOffLedger(namespace: string) {
return useLiveState(namespace, {
requested: null,
granted: null,
conflicts: [],
steps: [],
});
}
Step 2: The Orchestrator (Arrival → Resolve → Handoff → Evidence)
The orchestrator is intentionally shell-owned. Tracks can request changes, but the shell grants them and records the grant.
import { useLiveState, useRenderEffect, useRouteMiddleware, useRouteState, useScope, createScope } from "react";
import { AsWorkbenchIntent, useHandOffLedger } from "./intent";
function pickWinner(routeIntent: any, localIntent: any, policy: any) {
if (policy.winner === "route") return routeIntent;
if (policy.winner === "local") return localIntent;
// “latest” posture: whichever has the newest handoffId wins.
return String(localIntent.handoffId).length >= String(routeIntent.handoffId).length ? localIntent : routeIntent;
}
export function useHandOffOrchestrator(orchestratorId: string) {
const route = useRouteState();
const ledger = useHandOffLedger(`handoff:${orchestratorId}:ledger`);
const local = useLiveState(`handoff:${orchestratorId}:intent`, AsWorkbenchIntent({}));
// A scope contract publishes the hand-off policy.
const scope = useScope(
orchestratorId,
createScope({
policy: { winner: "route", allowTrackOverride: true, recordConflicts: true },
})
);
useRouteMiddleware((ctx) => {
const requested = AsWorkbenchIntent({
track: route.query.read("track"),
docId: route.query.read("docId"),
mode: route.query.read("mode"),
handoffId: route.query.read("handoffId"),
});
ledger.write("requested", requested);
ledger.write("steps", [...ledger.read("steps"), { type: "ARRIVE", requested, at: Date.now() }]);
// Local intent starts as a copy so the receiving track can mutate without re-parsing route.
local.write("track", requested.track);
local.write("docId", requested.docId);
local.write("mode", requested.mode);
local.write("handoffId", requested.handoffId);
});
useRenderEffect(() => {
const requested = ledger.read("requested") ?? AsWorkbenchIntent({});
const current = AsWorkbenchIntent(local.read());
const policy = scope.read("policy");
const winner = pickWinner(requested, current, policy);
// Conflict recording is part of the contract: drift is allowed, but it must be visible.
if (policy.recordConflicts === true && (winner.track !== requested.track || winner.docId !== requested.docId)) {
ledger.write("conflicts", [
...ledger.read("conflicts"),
{ requested, current, winner, at: Date.now() },
]);
}
ledger.write("granted", winner);
ledger.write("steps", [...ledger.read("steps"), { type: "GRANT", winner, at: Date.now() }]);
// The granted intent becomes the live hand-off contract.
local.write("track", winner.track);
local.write("docId", winner.docId);
local.write("mode", winner.mode);
local.write("handoffId", winner.handoffId);
});
return { route, scope, ledger, intent: local };
}
Step 3: Tracks Receive the Hand-off (One Deep Prop Cargo)
We pass a single cargo object to tracks: this keeps the hand-off stable through composition. See: "Deeply Nested Props Objects".
import { useLiveState, useRenderFetch } from "react";
export function InboxTrack({ cargo }) {
const inbox = useRenderFetch(`/api/inbox?mode=${cargo.intent.read("mode")}`);
return (
<section data-track="inbox" data-doc={cargo.intent.read("docId")}>
<h2>Inbox</h2>
<pre data-inbox>{JSON.stringify(inbox, null, 2)}</pre>
</section>
);
}
export function EditorTrack({ cargo }) {
const draft = useLiveState(`doc:${cargo.intent.read("docId")}:draft`, { body: "" });
return (
<section data-track="editor" data-doc={cargo.intent.read("docId")} data-mode={cargo.intent.read("mode")}>
<h2>Editor</h2>
<textarea
defaultValue={draft.read("body")}
onBlur={(e) => draft.write("body", e.target.value)}
/>
<pre data-ledger>{JSON.stringify(cargo.ledger.read(), null, 2)}</pre>
</section>
);
}
export function PreviewTrack({ cargo }) {
return (
<section data-track="preview" data-doc={cargo.intent.read("docId")}>
<h2>Preview</h2>
<pre>{JSON.stringify({ intent: cargo.intent.read(), scope: cargo.scope.read("policy") }, null, 2)}</pre>
</section>
);
}
export function HistoryTrack({ cargo }) {
const events = cargo.ledger.read("steps");
return (
<section data-track="history">
<h2>History</h2>
<pre data-steps>{JSON.stringify(events, null, 2)}</pre>
<pre data-conflicts>{JSON.stringify(cargo.ledger.read("conflicts"), null, 2)}</pre>
</section>
);
}
Step 4: The Shell (File Route + Component Router + Orchestrated Handoffs)
import {
createComponentRouter,
useDevtools,
useLiveState,
useProfileSignal,
useRenderEffect,
useRouteMiddleware,
useRouteState,
} from "react";
import { useHandOffOrchestrator } from "./orchestrator";
import { EditorTrack, HistoryTrack, InboxTrack, PreviewTrack } from "./tracks";
const tracks = createComponentRouter({
id: "workbench.tracks",
tracks: ["inbox", "editor", "preview", "history"],
});
const Views = { inbox: InboxTrack, editor: EditorTrack, preview: PreviewTrack, history: HistoryTrack };
function normalizeTrack(value: unknown) {
if (value === "inbox") return "inbox";
if (value === "editor") return "editor";
if (value === "preview") return "preview";
if (value === "history") return "history";
return "inbox";
}
export function WorkbenchShell() {
useDevtools("WorkbenchShell");
useProfileSignal("route.workbench.handoffs");
const route = useRouteState();
const ui = useLiveState("workbench.ui", { track: "inbox" });
const orchestrator = useHandOffOrchestrator("workbench");
useRouteMiddleware((ctx) => {
ui.write("track", normalizeTrack(route.query.read("track")));
});
useRenderEffect(() => {
const granted = orchestrator.ledger.read("granted");
const track = normalizeTrack(granted?.track ?? ui.read("track"));
ui.write("track", track);
tracks.setActive(track);
});
const track = normalizeTrack(ui.read("track"));
const View = Views[track] ?? InboxTrack;
const cargo = { ...orchestrator };
return (
<section data-route="file:/workbench" data-shell="handoff" data-track={track} data-handoff-id={cargo.intent.read("handoffId")}>
<header>
<h1>Workbench</h1>
<nav aria-label="Workbench tracks">
{tracks.tracks.map((t) => (
<button key={t} data-active={String(t === track)} onClick={() => ui.write("track", t)}>
{t}
</button>
))}
</nav>
<div data-policy={JSON.stringify(cargo.scope.read("policy"))} />
</header>
<View cargo={cargo} />
<footer>
<strong>Hand-off Ledger</strong>
<pre>{JSON.stringify(cargo.ledger.read(), null, 2)}</pre>
</footer>
</section>
);
}
Operational Checklist (Hand-off Standard)
- Treat arrivals as intent objects (wide input, derived shape).
- Share one state handle across segments (global-local).
- Resolve drift by policy in the shell, not ad hoc in tracks.
- Record requested vs granted plus conflicts in a render-visible ledger.