PVR Tech Studio

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.

4 min read
Updated August 17, 2026

Overview

Two kinds of data reach the shell:

PathUsed byWhy
Server Component awaits the data directlyThe first renderNumbers ship with the HTML
Route Handler exposes it over HTTPClient-side callers, polling, refreshA 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

FileRole
src/server/shellData.tsThe swap point. getNavBadges(), getNotifications()
src/app/[locale]/(app)/layout.tsxServer Component; awaits both, passes them down
src/app/api/nav-badges/route.tsGET — the same badge data over HTTP
src/app/api/notifications/route.tsGET — the same feed over HTTP
src/context/ServerDataContext.tsxDelivers 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'] ?? seedCount

Always 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

EndpointMethodReturns
/api/nav-badgesGETRecord<string, number> — route path to count
/api/notificationsGETAppNotification[]

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.

Was this page helpful?