Route Handlers & Server Data
The shell's live numbers — sidebar count badges and the header notification feed — are fetched on the server and delivered in the initial payload, so the client never issues a request for them and there is no fetch waterfall after hydration.
Overview
Two kinds of data reach the shell:
| Path | Used by | Why |
|---|---|---|
| Server Component awaits the data directly | The first render | Numbers ship with the HTML |
| Route Handler exposes it over HTTP | Client-side callers, polling, refresh | A real endpoint to call |
Both read the same module. That matters: a template with a mock endpoint and a separately-mocked server render has two things to replace and two chances to leave one behind.
The data itself is the two things a real deployment genuinely loads per request — open tickets, pending applicants, unread mail. Everything else in the template is seed content that belongs in the bundle.
Architecture & files
| File | Role |
|---|---|
src/server/shellData.ts | The swap point. getNavBadges(), getNotifications() |
src/app/[locale]/(app)/layout.tsx | Server Component; awaits both, passes them down |
src/app/api/nav-badges/route.ts | GET — the same badge data over HTTP |
src/app/api/notifications/route.ts | GET — the same feed over HTTP |
src/context/ServerDataContext.tsx | Delivers it to the consumers; falls back to the seed |
shellData.ts
├──→ (app)/layout.tsx (Server Component) → ServerDataProvider → Sidebar, useNotifications
└──→ app/api/*/route.ts (HTTP) → client-side callers
The route group layout is a Server Component
export default async function AppGroupLayout({children}: {children: ReactNode}) {
const [navBadges, notifications] = await Promise.all([getNavBadges(), getNotifications()])
return <AppShell serverData={{navBadges, notifications}}>{children}</AppShell>
}Both loads run concurrently. The shell itself stays a Client Component because it is entirely interactive — the Server Component wrapper exists purely to fetch.
Consumers degrade gracefully
ServerDataContext is a context rather than threaded props, because the consumers are a nav item deep
inside the sidebar and a hook in the header; prop-drilling would touch a dozen components to deliver two
values. When no host supplies anything the context is an empty object and each consumer falls back to its
bundled seed — which is exactly how the client-only edition behaves, unchanged.
Usage
Reading server data in a component
import {useServerData} from '@/context/ServerDataContext'
const {navBadges, notifications} = useServerData()
const count = navBadges?.['/apps/contacts'] ?? seedCountAlways keep a fallback. The same component renders in the client-only edition, where nothing is provided.
Calling the endpoints
const badges = await fetch('/api/nav-badges').then((r) => r.json())Adding an endpoint
// src/app/api/tickets/route.ts
import {NextResponse} from 'next/server'
import {getTickets} from '@/server/shellData'
export async function GET() {
return NextResponse.json(await getTickets(), {headers: {'cache-control': 'no-store'}})
}Add the loader to shellData.ts first, so the server render and the endpoint stay on one source.
API / Props
| Endpoint | Method | Returns |
|---|---|---|
/api/nav-badges | GET | Record<string, number> — route path to count |
/api/notifications | GET | AppNotification[] |
Both send cache-control: no-store: the numbers are meant to look live, and a cached count is a wrong
count.
export interface ServerData {
/** route path → badge count. */
navBadges?: Record<string, number>
notifications?: AppNotification[]
}Configuration & customization
Replace the two function bodies in src/server/shellData.ts with a query or an upstream fetch:
export async function getNavBadges(): Promise<Record<string, number>> {
const rows = await db.query('select route, count(*) …')
return Object.fromEntries(rows.map((r) => [r.route, r.count]))
}Both consumers pick it up unchanged — the server render and the HTTP endpoint. That is the whole point of the indirection.
Notes & gotchas
The layout does not fetch its own Route Handler, deliberately. A Server Component calling back into an HTTP endpoint it hosts adds a network hop to reach code it could call directly, and makes the render depend on the server being able to address itself. The handlers exist for clients, not for the server hosting them.
Badge numbers still animate on mount. They render through the shared animated-number component, which counts up after hydration in both editions. Server-fetching changed where the data comes from, not whether it animates — so a number appearing to tick up is not evidence the fetch happened on the client.
Notification actions stay client-side. Approve and reject are presentational, because there is no
backend to record a decision against. Point the handler at a real endpoint and add POST handlers for the
actions.
A Route Handler that reads the request cannot be statically exported. Not a concern for this edition — it runs as a server — but it is one of the reasons the app cannot be built as a static site. See Deploying the Next.js Edition.
/api/* is excluded from the request-time handler's matcher, so locale negotiation and the auth guard
never run against an endpoint. If you add an endpoint that should be protected, check the session inside
the handler with hasSession() rather than relying on the guard.
Related
Was this page helpful?
