PERF-14: Server Component Usage — Closing the Data-Fetching Gap
PERF-14: Server Component Usage — Closing the Data-Fetching Gap
Release: v0.1.177 · Category: Performance · Control: PERF-14
Background
Next.js App Router gives every page the opportunity to fetch data on the server and deliver a fully-populated HTML response to the browser. When that opportunity is missed, users experience a "loading waterfall": the page shell arrives, JavaScript hydrates, and then another network request goes out before any real content appears.
This post documents the PERF-14 audit findings and the recommended path to fix it.
What We Found
Across the dashboard, the data-fetching pattern looks like this:
Browser → Next.js Server
└─ RSC renders page shell
└─ SSR HTML delivered to browser
└─ JS bundle hydrates
└─ 'use client' component mounts
└─ fetch() → /api/trpc ← real data finally arrives
Pages including DashboardPage, AnalyticsPage, and PropertiesPage are declared as React Server Components, but their server-side execution only resolves auth/session data. All meaningful data fetching is deferred to large 'use client' child components (OverviewStats, WorkQueue, PropertiesList) that issue tRPC calls after hydration.
The result is that RSC status on these pages is effectively cosmetic — the user still waits for a full client-side round-trip before seeing data.
Why It Matters
| Metric | Current pattern | After remediation |
|---|---|---|
| Data available at first paint | ❌ No | ✅ Yes |
| Network round-trips on initial load | 2 (SSR + tRPC) | 1 (SSR only) |
| Loading skeletons visible to user | Always | Eliminated for initial render |
| Interactivity preserved | ✅ Yes | ✅ Yes |
For tenants and landlords landing on the dashboard after login, eliminating the waterfall means the work queue and property list are visible immediately — no skeleton screens on the critical path.
Recommended Migration Paths
Two approaches are available depending on component complexity. Both are compatible with the existing tRPC setup.
Option A: tRPC Server-Side Caller
Create a createCaller in page.tsx, execute the queries server-side, and pass results as props.
// src/app/dashboard/page.tsx
import { createCaller } from '@/server/api/root';
import { createTRPCContext } from '@/server/api/trpc';
export default async function DashboardPage() {
const ctx = await createTRPCContext({ headers: headers() });
const caller = createCaller(ctx);
// Prefetch on the server — no client round-trip needed
const [overviewStats, workQueue] = await Promise.all([
caller.dashboard.getOverviewStats(),
caller.dashboard.getWorkQueue(),
]);
return (
<>
<OverviewStats initialData={overviewStats} />
<WorkQueue initialData={workQueue} />
</>
);
}
Client components receive initialData and can use it directly, falling back to live tRPC queries only when the user performs an action that invalidates the cache.
Option B: HydrationBoundary Prefetching
Use trpc.prefetchQuery with React Query's HydrationBoundary to stream dehydrated query state into the client. This approach requires no prop changes to existing client components.
// src/app/dashboard/page.tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { createServerSideHelpers } from '@trpc/react-query/server';
export default async function DashboardPage() {
const helpers = createServerSideHelpers({ ... });
// Prefetch — results are serialised into the HTML payload
await helpers.dashboard.getOverviewStats.prefetch();
await helpers.dashboard.getWorkQueue.prefetch();
return (
<HydrationBoundary state={dehydrate(helpers.queryClient)}>
{/* These components find their data already in the cache */}
<OverviewStats />
<WorkQueue />
</HydrationBoundary>
);
}
This is lower friction for existing components since useQuery calls inside them will resolve immediately from the hydrated cache without any prop drilling.
Which Option Should You Use?
| Scenario | Recommended approach |
|---|---|
| New page or component | Option A (caller + props) — explicit, type-safe |
| Existing client components with many consumers | Option B (HydrationBoundary) — no prop changes needed |
| Components that mix server data with user interaction | Option B — preserves live-query behaviour post-hydration |
Affected Files
src/app/dashboard/page.tsx— primary remediation targetsrc/app/dashboard/analytics/page.tsx— same pattern appliessrc/app/dashboard/properties/page.tsx— same pattern applies
Current Status
This release tracks the audit finding. No code changes have been applied yet. Follow-on work will implement Option B (HydrationBoundary) across the three highest-traffic dashboard pages as the first remediation step.