Header architecture

How the header uses parallel routes, slots, and Suspense — and the drama of keeping it from making the entire page dynamic

This page is out of date

The @headerLeft / @headerCenter / @headerRight slots described below no longer exist. apps/web/app/[locale]/(site)/layout.tsx now renders a single <Header /> component, and @adminSheet is the only remaining slot. The locale wiring described here is current; the slot decomposition is not. This page needs a rewrite against the real header.

The architecture

The header uses Next.js parallel routes (slots) to split itself into independent segments that render and suspend independently:

/[locale]/(site)/
├── layout.tsx              ← orchestrator, receives all slots as props
├── @headerLeft/default.tsx
├── @headerCenter/default.tsx
├── @headerRight/default.tsx
└── @adminSheet/default.tsx

The site layout receives each slot as a ReactNode prop and passes them to HeaderWithSlots, which is just a CSS grid:

// apps/web/app/[locale]/(site)/layout.tsx
export default function SiteLayout({
  children, headerLeft, headerCenter, headerRight, adminSheet,
}: SiteLayoutProps) {
  // No `params`, no `setRequestLocale` — locale resolves via rootParams.
  return (
    <CartProvider>
      <HeaderWithSlots
        headerCenter={headerCenter}
        headerLeft={headerLeft}
        headerRight={headerRight}
        profile={<ButtonProfile />}
      />
      <main>{children}</main>
      <Suspense fallback={null}><CartSheet /></Suspense>
      <Suspense fallback={null}><AdminSheet content={adminSheet} /></Suspense>
    </CartProvider>
  );
}

HeaderWithSlots itself is a plain server component — a 3-column grid that renders whatever ReactNode it gets. The dynamic work happens inside the slots.

The slots

@headerLeft — logo or back button

The slot file is a sync server component — it takes no params, because the locale resolves through rootParams wherever a next-intl API is called:

// @headerLeft/default.tsx
export default function HeaderLeftSlot() {
  return (
    <HeaderLeft
      mobileNav={
        <Suspense fallback={<Skeleton />}>
          <MobileNav />
        </Suspense>
      }
    />
  );
}

HeaderLeft is a "use client" component. It wraps its inner logic in Suspense because HeaderLeftInner calls useHeaderSegments() — a client hook that reads useSelectedLayoutSegments(). Based on the current route, it shows either a back button (cart, checkout, chat, orders) or the default logo + mobile nav.

The Suspense fallback is the default state: logo on desktop, hamburger on mobile.

@headerCenter — search bar or route title

Also "use client". HeaderCenterInner calls both useHeaderSegments() and useTranslations() to decide what to show:

RouteShows
/cart"Cart" title
/checkout"Checkout" title
/chat"Messages" title
/account"Account settings" title
/setupLogo
/my/*Section title (mobile) + search bar (desktop)
Default (/)Logo (mobile) + search bar (desktop)

The SearchBox inside the center slot is itself wrapped in <Suspense fallback={<SearchBoxSkeleton />}> because it uses useRouter(), usePathname(), and useSearchParams().

@headerRight — action buttons or theme toggle

HeaderRightInner calls useCurrentUser() — which calls use(sessionPromise) to unwrap the session. If there's no user, it shows only the theme toggle. If logged in, it shows cart, messages, favorites, and sell buttons.

Suspense fallback: just <ButtonThemeToggle /> — the one thing that doesn't need auth.

ButtonProfile — always rendered, not a slot

ButtonProfile is passed directly as a prop from the layout (profile={<ButtonProfile />}), not through a slot. It also calls useCurrentUser() and suspends. Logged out: sign-in/join buttons. Logged in: avatar dropdown (desktop) or profile sidebar sheet (mobile).

The drama

Every single part of the header is dynamic. Here's why:

1. useHeaderSegments() — the route-awareness tax

export function useHeaderSegments() {
  const segments = useSelectedLayoutSegments();
  return segments.filter((s) => !s.startsWith("("));
}

This hook lets the header adapt to the current route — show a back button on /cart, a title on /chat, search bar on /. But useSelectedLayoutSegments() is a navigation hook. It reads the current URL, which is inherently dynamic. Any component that calls it must be inside a <Suspense> boundary or the entire page becomes dynamic.

Both HeaderLeft and HeaderCenter use it, so both need Suspense wrappers.

2. useCurrentUser() — the session tax

export function useCurrentUser() {
  const sessionPromise = use(Context);   // get promise from AuthProvider
  const session = use(sessionPromise);   // unwrap it (suspends!)
  return session?.user ?? null;
}

The session is fetched server-side as a promise and passed through context without being awaited. Client components unwrap it with use(), which triggers Suspense. This is by design — it lets the static shell render instantly while session-dependent UI streams in.

Both HeaderRight and ButtonProfile use it. Without their Suspense boundaries, the header would block the entire page until the session resolves.

3. AutoHideHeader — the scroll-tracking tax

The entire header is wrapped in AutoHideHeader, which calls useSelectedLayoutSegment() to detect if we're on an item detail page (where the header hides on mobile). It also uses a scroll-tracking hook with refs. All client-side, all dynamic.

4. SearchBox — the search bar tax

Uses useRouter(), usePathname(), useSearchParams(). Three navigation hooks in one component. Gets its own Suspense boundary with <SearchBoxSkeleton />.

The Suspense sandwich

The result is a tower of Suspense boundaries, each with a carefully chosen fallback:

AutoHideHeader (client, useSelectedLayoutSegment)
└── HeaderWithSlots (server, static grid)
    ├── HeaderLeft
    │   └── <Suspense fallback={logo + hamburger}>
    │       └── HeaderLeftInner (useHeaderSegments → back button or logo)
    │           └── <Suspense fallback={skeleton}>
    │               └── MobileNav (async server → fetches categories)

    ├── HeaderCenter
    │   └── <Suspense fallback={null}>
    │       └── HeaderCenterInner (useHeaderSegments + useTranslations)
    │           └── <Suspense fallback={SearchBoxSkeleton}>
    │               └── SearchBox (useRouter, usePathname, useSearchParams)

    ├── HeaderRight (desktop only)
    │   └── <Suspense fallback={ThemeToggle}>
    │       └── HeaderRightInner (useCurrentUser → action buttons)

    └── ButtonProfile
        └── <Suspense fallback={pulsing circle}>
            └── ButtonProfileInner (useCurrentUser → avatar or sign-in)

Every leaf that reads the route or the session gets its own boundary. The fallbacks are designed to look like the most common state (logo, search skeleton, theme toggle) so the page feels instant even though nothing in the header is truly static.

Locale resolution in slots

Slot files don't need setRequestLocale or params wiring. Locale is resolved automatically from the [locale] route segment via next/root-params, integrated once in apps/web/i18n/request.ts. The next-intl <Link>, getTranslations(), and getLocale() all work inside any slot without per-slot setup. See Locale resolution in parallel routes for the full story.

Why not "use cache"?

None of the header components can use "use cache" because:

  • useHeaderSegments() reads the live URL
  • useCurrentUser() reads the live session
  • usePathname() / useSearchParams() read the live URL
  • AutoHideHeader reads the live scroll position

These are all per-request, per-user values. There's nothing to cache. The header is fundamentally dynamic — the architecture just makes sure it suspends gracefully instead of blocking the page.

The default.tsx vs page.tsx thing

Slots use default.tsx, not page.tsx. A default.tsx renders when no route-specific page.tsx matches. Since our slots don't have route overrides (except @adminSheet), they always render the default. If you forget default.tsx and only create page.tsx, the slot will return null on routes it doesn't explicitly handle — and the header section disappears.

@adminSheet is the one slot that does use route overrides: @adminSheet/checkout/page.tsx shows the admin checkout panel, @adminSheet/cart/page.tsx returns null. Everything else falls through to the default.