React Server Components 2026: explained with Next.js 15 code

# tutorials · react

React Server Components 2026: explained with runnable Next.js 15 code.

React Server Components (RSC) render on the server, ship zero client JavaScript, and stream to the browser. This tutorial explains what they actually are, when to use them, when to reach for `use client`, and how streaming and the `use()` hook fit together in a real Next.js 15 App Router project.

What RSC actually are

A React Server Component is a component that runs on the server, produces HTML, and never ships JavaScript to the browser. It can read from a database directly, call an API with a private key, or import a heavy library (like markdown parsers or PDF generators) without adding a byte to the client bundle.

The trade-off: server components cannot use hooks (useState, useEffect), cannot attach event handlers, and cannot access window or document. For that, you drop a 'use client' boundary.

A server component: fetch and render

In Next.js 15 App Router, every component in app/ is a server component by default. This one fetches posts from a database directly and renders them.

// app/posts/page.tsx
import { db } from '@/lib/db';

export default async function PostsPage() {
  const posts = await db.query.posts.findMany({
    orderBy: (p, { desc }) => desc(p.publishedAt),
    limit: 10,
  });

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>
          <a href={`/posts/${p.slug}`}>{p.title}</a>
        </li>
      ))}
    </ul>
  );
}

No API route, no useEffect, no loading spinner. The database driver runs on the server; the browser only sees the rendered HTML. Client JavaScript for this component: 0 bytes.

A client component: interactivity

When you need useState, onClick, or browser APIs, add 'use client' at the top of the file.

// components/like-button.tsx
'use client';

import { useState } from 'react';

export function LikeButton({ postId }: { postId: string }) {
  const [likes, setLikes] = useState(0);

  return (
    <button
      onClick={() => {
        setLikes((n) => n + 1);
        fetch('/api/like', { method: 'POST', body: JSON.stringify({ postId }) });
      }}
    >
      Like ({likes})
    </button>
  );
}

Then compose the client component inside a server component. The server component sends the client-component payload to the browser; the browser hydrates only that leaf, not the whole tree.

// app/posts/[slug]/page.tsx (server component)
import { db } from '@/lib/db';
import { LikeButton } from '@/components/like-button';

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await db.query.posts.findFirst({
    where: (p, { eq }) => eq(p.slug, slug),
  });

  if (!post) return <p>Not found.</p>;

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <LikeButton postId={post.id} />
    </article>
  );
}

Streaming with Suspense

React 19 lets you stream chunks of a server-rendered page as they finish, instead of waiting for the slowest fetch. Wrap the slow part in <Suspense> and give it a fallback.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserStats } from './user-stats';
import { RecentOrders } from './recent-orders';

export default function Dashboard() {
  return (
    <>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading stats</p>}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<p>Loading orders</p>}>
        <RecentOrders />
      </Suspense>
    </>
  );
}

The <h1> and the two fallbacks stream to the browser immediately. Each <Suspense> child streams its real content as its data resolves. Time-to-first-byte drops from “slowest fetch” to “instant”.

The use() hook: read promises inside client components

The use() hook (stable in React 19) reads a promise and unwraps its resolved value. In a client component, you can pass a promise down from a server component and use use() to consume it.

// app/orders/page.tsx (server)
import { OrderList } from './order-list';
import { fetchOrders } from '@/lib/orders';

export default function OrdersPage() {
  const ordersPromise = fetchOrders();  // No await, pass the promise
  return (
    <div>
      <h1>Orders</h1>
      <OrderList ordersPromise={ordersPromise} />
    </div>
  );
}

// components/order-list.tsx (client)
'use client';
import { use } from 'react';
import type { Order } from '@/lib/types';

export function OrderList({ ordersPromise }: { ordersPromise: Promise<Order[]> }) {
  const orders = use(ordersPromise);
  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id}>{o.total} on {o.createdAt}</li>
      ))}
    </ul>
  );
}

The server component starts the fetch and streams the shell. The client component reads the promise as soon as it arrives, wrapped in <Suspense> for the fallback. No useEffect, no loading state management.

When to reach for a client component

  • Interactive UI: buttons, modals, tabs, sliders.
  • Anything that uses useState, useEffect, useRef.
  • Browser APIs: window, localStorage, IntersectionObserver, fetch from the client.
  • Third-party client libraries: charts, editors, maps, drag-and-drop.

When a server component is the right answer

  • Reading from a database or private API.
  • Rendering markdown, PDFs, or heavy transforms on the server.
  • SEO-critical content that must be in the initial HTML.
  • Anywhere you can move code off the client bundle without breaking interactivity.

affiliate disclosure: Web Dev Blog carries affiliate links to Kinsta, WP Engine, Cloudways, DigitalOcean, and Vercel. If you sign up via a link on this site we may receive a commission at no cost to you. This does not influence editorial verdicts, which are based on real tests from a London server. Full policy on /affiliate-disclosure/.