[Deep Dive #56] App Router Hand-offs - discussion
Deep Dive #56 is about keeping navigation fast while vocabulary stays mixed: file router as the public address layer, component router as the operational layer, and a repeatable hand-off contract between segments.
The contract invariants are simple: one intent object, one shared state handle (global-local), one ledger recording requested vs granted decisions, and one shell owning conflict policy. The receiving segment must not have to re-infer what happened.
What does an intent object look like in your app (string route id, structured object, versioned contract)? Where do you draw the boundary for who can resolve conflicts (shell only, group covenant, track-level)? Do you render hand-off evidence in the UI (intent id, state handle id, granted decisions), or keep it in logs?
Comments (10)
Back to latestI like that it reframes the question. 'Choosing a router' isn't the problem; hand-offs are.
Related: The App Router Mindset and Architecture Guides.
The hand-off requirement that the receiver must not 're-infer' is huge. That's where most routing bugs come from.
If the receiver guesses wrong, you get phantom state and weird redirects.
We started rendering a tiny hand-off footer in dev builds and it helped a lot:
tsx
return <footer data-intent={intent.id} data-handle={handle.id} data-granted={ledger.read('granted').join(',')} />;Counterpoint: intent objects can become unbounded. Everyone adds fields until it's a mini framework.
I'd rather version intent shapes and treat them like API payloads with a smuggling lane for extras.
The connection to 'events are route transitions' is subtle but important: interactions become navigation decisions, so the hand-off ledger is basically your event log in UI form.
Related: Events Are Route Transitions.
Our intent object is versioned and deliberately small: intentId, scenario, handoffId, and one payload bag.
Everything else is either derived in the receiver or stored in the shared handle.
Conflict policy being shell-owned is the part I wish more routing docs said explicitly.
If tracks can resolve conflicts, you get subtle behavior drift where the same intent yields different outcomes depending on which component happened to render first.
We implemented a tiny orchestrator that records requested vs granted as strings so support can read it in screenshots:
tsx
import { useLiveState, useRouteState } from "react";
export function useHandOffLedger(namespace: string) {
return useLiveState(namespace, { requested: null as any, granted: [], handoffId: "" });
}
export function useHandOffOrchestrator(orchestratorId: string) {
const route = useRouteState();
const ledger = useHandOffLedger(`handoff:${orchestratorId}:ledger`);
ledger.write("requested", route.read("intent"));
ledger.write("granted", ["track:grid", "policy:shell-latest"]);
return ledger;
}Counterpoint: rendering hand-off evidence everywhere can become noisy (and sometimes sensitive).
We keep full evidence in QA and render a compact "handoff code" in prod unless the user opts into verbose mode.
Yeah, I like that as a posture. The point is that evidence exists and is consistent, not that the UI is a debug console.
A compact code that maps to a ledger entry still preserves auditability.
This post made me go back and re-read the architecture guides. It's basically applying "contracts live at boundaries" to routing handoffs.
Related: Architecture Guides.