Conventions

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

RegionOwnerContent source
Primary navigation, app header, default breadcrumb, workspace and footerapp/(dashboard)/layout.tsx, through AppShellShared catalogue and UI preferences
Full filter rail and its headerDashboard layoutThe route's filter controls
Important-filter barDashboard layoutCompact controls bound to the same URL state as the full panel
Auxiliary rail frameThe nearest @rightSidebar/<route>/layout.tsxIts matching parallel-route page
Fixed auxiliary-rail header or search fieldThe auxiliary-rail layoutLayout-owned client control when it reads the current URL
Auxiliary-rail result listThe matching @rightSidebar/.../page.tsxServer data for that route
Identity header or tabs shared by child routesThe nearest main-tree layoutA cached server read or a client island under Suspense
Heading inside an article, card, table or one-off resultThe page contentThe component that renders that document section
Sheet, dialog or row actionThe interactive leaf componentLocal 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.tsx

The layout constructs the persistent search header and rail. The two pages return only lists:

app/(dashboard)/@rightSidebar/teams/layout.tsx
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>
  );
}
app/(dashboard)/@rightSidebar/teams/page.tsx
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 inputRequired boundary
params or searchParams used by route chromeAsync child under <Suspense>
cookies() or headers()Request-time island under <Suspense>
User-specific rail contentRequest-time island; never cache across users
Public list dataServer component with the repository cache policy
MetadataStatic 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.

On this page