Header architecture
One Header component, three grid slots, one Suspense boundary for the whole right-hand group — and how the header knows which routes replace it
The shape
There are no @headerLeft / @headerCenter / @headerRight parallel routes. The site layout renders a single <Header />; @adminSheet is the only slot left.
// apps/web/app/[locale]/(site)/layout.tsx — sync, no params, no locale wiring
export default function SiteLayout({ children, adminSheet }: SiteLayoutProps) {
const cartPromise = getCart(); // started, not awaited
return (
<CartContext value={cartPromise}>
<Suspense fallback={null}>
<ScrollToTopOnNavigation />
</Suspense>
<Suspense fallback={null}>
<ImpersonationBanner />
</Suspense>
<Header />
<main className="flex flex-1 flex-col">{children}</main>
<Suspense fallback={null}>
<MobileTabBar />
</Suspense>
<CartSheet />
<Suspense fallback={null}>
<StaffPanel content={adminSheet} />
</Suspense>
</CartContext>
);
}The header itself lives in packages/features/navigation/components/header/. AppHeader / AppHeaderLeft / AppHeaderCenter / AppHeaderRight (header/header-with-slots/) are layout primitives — a grid-cols-[auto_1fr_auto] bar and three positioned divs. "Slots" here means grid areas, not routing.
| Area | Contains |
|---|---|
| Left | SearchTrigger (desktop only) + the proximity-search filter, behind showProximitySearch |
| Center | Logo, plus the search trigger on its own row below lg |
| Right | HeaderRight — market selector, cart, desktop trio, profile |
Which routes get the header
useSiteHeaderMode() (navigation/hooks/use-site-header-mode.ts) is the one piece of route knowledge the header keeps: whether the current route replaces it. Three modes — browse, mobile-hidden (desktop keeps the browse header, mobile gets the route's own MobilePageBar), hidden (the route renders a PageBar at every size).
Focused flows are hidden: /bundle, /onboarding, /checkout, /group/new, the item form (/items/new, /items/[id]/edit). mobile-hidden covers /account, /balance, /cart, /chat, /my, /orders, and /items/[id] (the immersive detail, overlay controls on mobile).
It reads usePathname(), not a marker emitted from the route's own tree, and that is deliberate: Next 16 keeps previously visited segments mounted but hidden (Activity keep-alive) for instant back-navigation, so anything global emitted from route content keeps applying after you navigate away. The pathname is the only per-route signal that is both SSR-correct and immune to kept-alive trees.
Adding a route with its own bar = add it to that switch and mount the bar there.
Visibility is class-toggled, never unmounted, so header state (cart badge, unread counts, logo animation) survives route changes.
One boundary for the right-hand group
HeaderRight wraps the whole group in a single <Suspense>, not one boundary per button:
export function HeaderRight() {
return (
<Suspense fallback={<HeaderRightSkeleton />}>
<MarketSlot />
<CartSlot />
<DesktopButtons />
<ButtonProfile />
</Suspense>
);
}Every slot in there resolves from the same tier — JWT claims for the buttons, the visitor market context for the globe — and none touches the database. With a boundary each, whichever settled first painted alone; sharing one costs nothing and the row arrives in one piece.
The fallback is two circles, for the two slots both auth states share at every breakpoint: the country flag and the trailing account control. The row is right-aligned, so everything else mounts to the left into empty space at its final position — nothing already painted moves. Placeholders for the rest would have to guess the auth state (bag, messages, favorites, the sell pill are all signed-in-only), and a wrong guess is a promise the resolve breaks.
Two auth promises, not one
AuthContext hands the client two unawaited promises, because auth has two costs:
| Hook | Reads | Use it for |
|---|---|---|
useIdentity() | getSession() — JWT claims, no database | "Signed in?", "admin?", "which country?", and every decision about which controls to render |
useCurrentUser() | getUser() — claims + two queries | Presentation fields: username, avatar, isFeatured |
Both are passed down unawaited so the layout's static shell holds. Read the cheapest one that answers your question.
This is why ButtonProfile splits: avatar or sign-in links is a claims question (useIdentity), so it resolves without touching the database; the avatar's content is not, so ProfileAvatar gets its own inner boundary whose fallback is the same 40px circle it resolves into.
MarketSlot, CartSlot and DesktopButtons are all plain useIdentity() checks returning null for visitors — a visitor has no cart to count, and a member's country is already settled, so the market selector shows for visitors only and lives on in the profile sheet (mobile) and the footer (desktop).
Scroll state
StickyHeader is the only client wrapper around the bar. A zero-height sentinel above the sticky box is watched by an IntersectionObserver; when it leaves the viewport data-scrolled flips and the header swaps its resting hairline for a shadow. A scroll listener would fire every frame to learn the same one bit.
That data-scrolled toggle is only the fallback: .site-header-bar (ui-web/styles/animations.css) scrubs the same change with scroll position wherever scroll-driven animations are supported.
Why not "use cache"?
Nothing in the header is cacheable. useSiteHeaderMode() reads the live URL, the auth hooks read the live session, SearchTrigger reads live search params, StickyHeader reads live scroll. All per-request, per-user. The architecture's job is not to cache the header but to make it suspend gracefully instead of blocking the page.
Locale
No params wiring, no setRequestLocale. Locale resolves from the [locale] root param, integrated once in apps/web/i18n/request.ts — getTranslations(), getLocale() and the next-intl <Link> all work anywhere in the header. See Locale resolution in parallel routes.
@adminSheet
The one real parallel route left. Slots use default.tsx, not page.tsx: a default.tsx renders when no route-specific page.tsx matches. @adminSheet uses route overrides (@adminSheet/checkout/page.tsx shows the admin checkout panel); everything else falls through to the default. Its intercept pages carry export const instant = false, since AdminSheetSlot only mounts them when the sheet is open.