Back to Blog

React Server Components in Next.js 15: Client/Server Boundary, Streaming, and Suspense

Master React Server Components in Next.js 15 — server vs client component rules, data fetching patterns, Suspense streaming, client/server boundary mistakes, ca

Viprasol Tech Team
June 16, 2026
13 min read

React Server Components in Next.js 15: Client/Server Boundary, Streaming, and Suspense

React Server Components (RSC) fundamentally change how you think about data fetching. In Next.js 15, components are server-side by default — they run only on the server, can query the database directly, and ship zero JavaScript to the client. The result is smaller bundles, faster initial loads, and a simpler data-fetching model.

The learning curve is understanding the rules of the boundary between server and client.


Server vs Client Components

Server ComponentClient Component
Runs onServer onlyServer (initial HTML) + client (hydration + updates)
Access toDatabase, secrets, filesystem, server APIsBrowser APIs, event handlers, state, refs
Can useasync/await, direct DB queriesuseState, useEffect, useRef, event listeners
Bundle impactZero JS shipped to clientShips JS bundle
Default in Next.js 15✅ Yes❌ Must add 'use client'

The mental model: server components are for data. Client components are for interactivity.

// app/orders/page.tsx — Server Component (default)
// Can query DB directly — no API route needed
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';

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

  // Direct DB query — runs on server, never exposed to client
  const orders = await db.orders.findMany({
    where: { userId: session.user.id },
    orderBy: { createdAt: 'desc' },
    take: 20,
  });

  return (
    <div>
      <h1>Your Orders</h1>
      {/* OrderList is also a server component — no 'use client' */}
      <OrderList orders={orders} />
    </div>
  );
}
// components/OrderList.tsx — Server Component
// Renders a list; no interactivity needed → stay server-side
import type { Order } from '@/lib/types';

export function OrderList({ orders }: { orders: Order[] }) {
  return (
    <ul>
      {orders.map(order => (
        <li key={order.id}>
          <span>{order.id}</span>
          <span>${(order.totalCents / 100).toFixed(2)}</span>
          {/* OrderStatusBadge is a server component too */}
          <OrderStatusBadge status={order.status} />
          {/* CancelButton needs onClick → must be client component */}
          <CancelButton orderId={order.id} />
        </li>
      ))}
    </ul>
  );
}
// components/CancelButton.tsx — Client Component
'use client';  // This directive makes it a client component

import { useState } from 'react';
import { cancelOrder } from '@/app/actions';

export function CancelButton({ orderId }: { orderId: string }) {
  const [loading, setLoading] = useState(false);

  const handleCancel = async () => {
    setLoading(true);
    await cancelOrder(orderId);
    setLoading(false);
  };

  return (
    <button onClick={handleCancel} disabled={loading}>
      {loading ? 'Cancelling...' : 'Cancel Order'}
    </button>
  );
}

The Boundary Rules

Understanding what can cross the server/client boundary prevents the most common RSC bugs:

// ✅ You CAN pass serializable data across the boundary
// Strings, numbers, arrays, plain objects, Date, null, undefined
<ClientComponent
  title="Hello"
  count={42}
  items={[{ id: '1', name: 'Item' }]}
  date={new Date()}
/>

// ❌ You CANNOT pass non-serializable data
// Functions, class instances, Promises (directly), Map, Set
<ClientComponent
  onClick={() => {}}     // ❌ Functions can't cross boundary
  db={db}               // ❌ Class instances can't cross boundary
  promise={fetchData()}  // ❌ Raw promises can't cross boundary (use Suspense)
/>

// ✅ Server Actions CAN be passed to client components
// They're serialized as special references
import { cancelOrder } from '@/app/actions';  // 'use server' action
<ClientComponent onCancel={cancelOrder} />  // ✅ Server action reference

The composition pattern — keep client components as leaves, pass data down from server:

// ✅ Correct: Server component wraps client component
// Data flows server → client; interactivity in leaves only
export default async function DashboardPage() {
  const stats = await getStats();  // Server-side DB query

  return (
    <div>
      <StatsGrid stats={stats} />          {/* Server component */}
      <InteractiveChart data={stats.chartData} /> {/* Client component */}
    </div>
  );
}

// ❌ Avoid: importing a server component inside a client component
'use client';
import { DataTable } from './DataTable';  // If DataTable is a server component — ERROR
// Server components can't be imported into client components
// (they can be passed as `children` prop, but not imported directly)

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Data Fetching Patterns

// Pattern 1: Parallel data fetching (don't waterfall)

// ❌ Sequential — each awaits the previous
export default async function UserPage({ params }: { params: { id: string } }) {
  const user = await getUser(params.id);          // Wait 50ms
  const orders = await getOrders(params.id);      // Wait another 50ms
  const activity = await getActivity(params.id);  // Wait another 50ms
  // Total: 150ms
}

// ✅ Parallel — all fire at once
export default async function UserPage({ params }: { params: { id: string } }) {
  const [user, orders, activity] = await Promise.all([
    getUser(params.id),
    getOrders(params.id),
    getActivity(params.id),
  ]);
  // Total: ~50ms (max of the three)
}

// Pattern 2: Caching fetch results
import { unstable_cache } from 'next/cache';

const getCachedStats = unstable_cache(
  async (tenantId: string) => {
    return db.stats.aggregate({ where: { tenantId } });
  },
  ['tenant-stats'],  // Cache key prefix
  {
    revalidate: 60,  // Seconds before revalidating
    tags: ['stats'], // For on-demand invalidation
  }
);

// Invalidate on mutation
import { revalidateTag } from 'next/cache';
await revalidateTag('stats');  // All cached calls with tag 'stats' are invalidated

Suspense and Streaming

Suspense lets you stream parts of the page as they become ready, rather than waiting for all data before sending any HTML:

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div>
      {/* Renders immediately — no data needed */}
      <DashboardHeader />

      {/* Streams in when data is ready */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />   {/* async server component — fetches DB */}
      </Suspense>

      {/* Renders independently — doesn't wait for StatsPanel */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />  {/* async server component — separate DB query */}
      </Suspense>
    </div>
  );
}
// components/StatsPanel.tsx — async server component
export async function StatsPanel() {
  // This fetch doesn't block the rest of the page from rendering
  const stats = await db.stats.findFirst({ ... });

  return (
    <div className="grid grid-cols-3 gap-4">
      <StatCard label="MRR" value={`$${stats.mrr}`} />
      <StatCard label="Active Users" value={stats.activeUsers} />
      <StatCard label="Churn Rate" value={`${stats.churnRate}%`} />
    </div>
  );
}

Error boundaries with Suspense:

// app/dashboard/error.tsx — Next.js automatic error boundary
'use client';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="rounded-lg border border-red-200 p-4">
      <p className="text-red-700">Failed to load dashboard: {error.message}</p>
      <button onClick={reset} className="mt-2 text-sm text-red-600 underline">
        Try again
      </button>
    </div>
  );
}

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Server Actions

Server Actions replace API routes for form mutations. They're async functions with 'use server' that run on the server and can be called from client components:

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function cancelOrder(orderId: string): Promise<{ success: boolean; error?: string }> {
  const session = await auth();
  if (!session) return { success: false, error: 'Unauthorized' };

  // Verify ownership
  const order = await db.orders.findFirst({
    where: { id: orderId, userId: session.user.id },
  });

  if (!order) return { success: false, error: 'Order not found' };
  if (order.status !== 'PENDING') return { success: false, error: 'Order cannot be cancelled' };

  await db.orders.update({
    where: { id: orderId },
    data: { status: 'CANCELLED' },
  });

  // Invalidate the orders page cache
  revalidatePath('/orders');

  return { success: true };
}
// Client component using the server action
'use client';
import { useTransition } from 'react';
import { cancelOrder } from '@/app/actions';

export function CancelButton({ orderId }: { orderId: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => startTransition(() => cancelOrder(orderId))}
      disabled={isPending}
    >
      {isPending ? 'Cancelling...' : 'Cancel Order'}
    </button>
  );
}

Common Mistakes

// ❌ Mistake 1: useEffect for data fetching in a server component
// Server components are async — just await directly
export default async function Page() {
  useEffect(() => { /* This is a client API — not available in server components */ }, []);
}

// ❌ Mistake 2: Marking too many components 'use client'
// Only components with interactivity need 'use client'
// Data display, layout, static content = server components

// ❌ Mistake 3: Putting secrets in client components
'use client';
const apiKey = process.env.STRIPE_SECRET_KEY;  // Exposed in browser bundle!
// Server components handle secrets — pass only derived, safe data to client

// ❌ Mistake 4: Not using Suspense for slow data
// Without Suspense, the entire page waits for the slowest query
export default async function Page() {
  const slowData = await verySlowQuery();  // 3 seconds — blocks everything
  return <Page data={slowData} />;
}
// ✅ Fix: wrap in Suspense so the rest of the page renders immediately

Working With Viprasol

We build Next.js 15 applications with the RSC architecture from the ground up — server component design, data fetching optimization, streaming with Suspense, and Server Actions that replace REST endpoints for mutations.

Talk to our team about Next.js architecture and full-stack development.


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.