Skip to main content
All Docs
FeaturesBlockManOSUpdated March 26, 2026

Eliminating Client-Side Waterfalls with React Server Components

Eliminating Client-Side Waterfalls with React Server Components

Release: v1.0.78 · Performance · PERF-14

Background

Next.js App Router pages are Server Components by default. A common pattern when incrementally adopting the App Router is to write a page.tsx that handles authentication and then delegates everything else — including data fetching — to a single "use client" component.

This pattern is safe and correct, but it leaves significant performance on the table. The client receives an HTML shell with no data, hydrates, and only then begins fetching. Every user pays for an extra round trip on every first page load.

The Problem (Before v1.0.78)

All dashboard page.tsx files followed this minimal server-component pattern:

// Typical pre-v1.0.78 dashboard page
import { auth } from '@/server/auth';
import { redirect } from 'next/navigation';
import { MaintenanceClient } from './MaintenanceClient';

export default async function MaintenancePage() {
  const session = await auth();
  if (!session) redirect('/login');

  // No data fetching — client does everything
  return <MaintenanceClient />;
}

The sequence looked like this:

Browser                        Server
  |--- GET /dashboard/maintenance -->|
  |<-- HTML shell (no data) ------  |
  |    [hydrate]                     |
  |--- tRPC: listRequests() -------> |
  |<-- JSON payload --------------- |
  |    [render data]                 |

Two sequential server round trips before the user sees any content.

The Fix (v1.0.78)

The Server Component now calls the tRPC server-side caller directly, fetching the initial dataset before streaming the response to the browser.

// src/app/dashboard/maintenance/page.tsx
import { auth } from '@/server/auth';
import { redirect } from 'next/navigation';
import { createCaller } from '@/server/api/root';
import { createTRPCContext } from '@/server/api/trpc';
import { MaintenanceClient } from './MaintenanceClient';

export default async function MaintenancePage() {
  const session = await auth();
  if (!session) redirect('/login');

  const ctx = await createTRPCContext({ session });
  const caller = createCaller(ctx);

  const initialRequests = await caller.maintenance.listRequests({ limit: 20 });

  return <MaintenanceClient initialData={initialRequests} />;
}

The client component receives initialData and passes it to useQuery:

// MaintenanceClient.tsx
'use client';
import { trpc } from '@/utils/trpc';

export function MaintenanceClient({ initialData }) {
  const { data } = trpc.maintenance.listRequests.useQuery(
    { limit: 20 },
    { initialData }   // ← skips the client-side fetch on first render
  );

  // ...
}

The updated sequence:

Browser                        Server
  |--- GET /dashboard/maintenance -->|
  |                     [fetch data] |
  |<-- HTML with data embedded ---- |
  |    [hydrate — data already here] |

One round trip. Data is visible as soon as the page loads.

When to Apply This Pattern

Use server-side prefetching on dashboard pages where:

  • The data is required for the page to be useful (not lazy-loaded panels).
  • The query parameters are known at render time (no user-driven filters needed before first paint).
  • The dataset is small enough to be included in the initial HTML without bloating page size (a paginated first page of 20 records is ideal).

For secondary panels, infinite scroll, or data that depends on client-side state, continue fetching from the client component as before.

Pages Affected in This Release

PageStatus
dashboard/maintenance✅ Updated in v1.0.78

Other key dashboard pages are candidates for the same treatment in upcoming releases.