Route layout ownership
Which App Router file owns navigation, headers, filters, search rails, and other persistent route chrome.
Persistent route chrome is mounted by the nearest shared layout.tsx. A page
module supplies the content that changes at that URL; it does not construct the
frame around that content.
Ownership
| Region | Owner | Content source |
|---|---|---|
| Primary navigation, app header, default breadcrumb, workspace and footer | app/(dashboard)/layout.tsx, through AppShell | Shared catalogue and UI preferences |
| Full filter rail and its header | Dashboard layout | The route's filter controls |
| Important-filter bar | Dashboard layout | Compact controls bound to the same URL state as the full panel |
| Auxiliary rail frame | The nearest @rightSidebar/<route>/layout.tsx | Its matching parallel-route page |
| Fixed auxiliary-rail header or search field | The auxiliary-rail layout | Layout-owned client control when it reads the current URL |
| Auxiliary-rail result list | The matching @rightSidebar/.../page.tsx | Server data for that route |
| Identity header or tabs shared by child routes | The nearest main-tree layout | A cached server read or a client island under Suspense |
| Heading inside an article, card, table or one-off result | The page content | The component that renders that document section |
| Sheet, dialog or row action | The interactive leaf component | Local state unless the open item is linkable |
“Header” means persistent route chrome in this convention. A <header> that
labels one card or result section remains content.
File contract
Layout modules
A layout module may construct:
- the rail, panel, sticky header or persistent tab frame;
- loading and empty chrome for that region;
- providers whose lifetime must span navigation between child pages;
- a client island that reads the pathname for active navigation state.
Put the frame in the nearest layout that shares its lifetime. Do not move a team-only rail to the dashboard root, and do not repeat it in every team page.
Page modules
A page module may supply:
- the main route content;
- the changing rows inside a layout-owned parallel rail;
- data and controls unique to that one leaf route.
A page.tsx must not import PageRightSidebar or AppShell. ESLint enforces
that boundary, and route-layout-ownership.test.ts verifies every populated
right-sidebar page has a rail-owning ancestor layout.
Parallel route shape
Quick Lookup uses this shape:
app/(dashboard)/
├── (entities)/teams/page.tsx
└── @rightSidebar/
├── default.tsx
├── [...catchAll]/page.tsx
└── teams/
├── layout.tsx
├── page.tsx
└── search/[query]/page.tsxThe layout constructs the persistent search header and rail. The two pages return only lists:
import { EntityLookupRightSidebar } from "@/components/layout/entity-lookup-right-sidebar";
export default function TeamsRightSidebarLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<EntityLookupRightSidebar
basePath="/teams"
placeholderKey="filters.searchTeams"
placeholderFallback="Search teams"
>
{children}
</EntityLookupRightSidebar>
);
}import { Suspense } from "react";
import { TeamsLookup } from "@/composed/entity-lookups/entity-lookup-pages";
export default function TeamsRightSidebarPage() {
return (
<Suspense fallback={null}>
<TeamsLookup railOnly />
</Suspense>
);
}Every parallel slot needs default.tsx for a hard load. The right-sidebar slot
also has a content-free catch-all: Next retains the active slot page during a
soft navigation when no sibling matches, so the catch-all clears a Teams,
Players or Matchday rail instead of carrying it onto the next route. Do not add
catch-alls to slots that participate in fallback rewrites.
Filters
The dashboard layout owns both filter homes:
- the complete panel;
- the important-filter bar above the workspace.
The controls live under src/components/filters/. The full panel is the
complete set. A control mirrored into the bar binds to the same state; the bar
is never its only home. Configurable filter, tab, sort, date and lookup state
belongs in the URL as described in Where state lives.
The current filter portal is a content seam into layout-owned targets. It does not transfer ownership of the panel or header to the feature page. Do not add a new shell target or construct a second filter frame inside page content.
Runtime data and PPR
Layouts must keep their static shell outside runtime reads.
| Runtime input | Required boundary |
|---|---|
params or searchParams used by route chrome | Async child under <Suspense> |
cookies() or headers() | Request-time island under <Suspense> |
| User-specific rail content | Request-time island; never cache across users |
| Public list data | Server component with the repository cache policy |
| Metadata | Static metadata when possible; no locale cookie read in generateMetadata() |
Do not await params, searchParams, cookies() or headers() at the top of
an instant layout or page. That blocks prerendering before React can reach the
boundary. Pass the promise into an async child and suspend there:
import { Suspense } from "react";
export default function Layout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ teamId: string }>;
}) {
return (
<>
<Suspense fallback={<div className="h-16" aria-hidden="true" />}>
<TeamHeader params={params} />
</Suspense>
{children}
</>
);
}
async function TeamHeader({ params }: { params: Promise<{ teamId: string }> }) {
const { teamId } = await params;
return <div data-team-header={teamId} />;
}Use instant = false only when the whole route genuinely cannot render until
the runtime read completes.
Legacy seam
RightSidebarContent remains for the 100 Club's stateful rows. Its route layout
owns the rail and list header; only the rows cross the portal. New rails use
direct layout children. Remove this exception when the selected player and
watchlist view have a layout-scoped owner.
Review checklist
- Persistent regions have one layout owner.
- A populated parallel-route page has a matching ancestor layout.
- A soft navigation to an unrelated route clears every auxiliary slot.
- Full and important filter controls share one state owner.
- Runtime reads suspend below the static layout shell.
- Main pages contain route content, not app-shell or rail constructors.
- Desktop, mobile, hard-load and soft-navigation paths show the same controls.
Related
Overlays
Dialog is the web default and does not exist in the native kit — mobile uses sheets, and how a screen is presented lives in a route table rather than in the screen.
Where state lives
On web the server fetches server state and passes it as props; the URL owns the rest, through nuqs. On mobile React Query owns server state and a zustand store owns the rest, because there is no URL to put it in.

