Skip to main content
Back to blog

React Server Components in Next.js 15: complete guide

Ray MartínRay Martín
11 min read
React Server Components in Next.js 15: complete guide

React Server Components (RSC) have fundamentally changed how we build web applications with Next.js. In 2026, they're no longer an experimental feature — they're the standard. This article takes a deep dive into how they work, when to use them, and the advanced patterns you should master.

What are React Server Components?

Server Components are React components that run exclusively on the server. Unlike Client Components, they never send JavaScript to the browser. This means you can directly access databases, internal APIs, and the file system without exposing logic to the client.

In Next.js 15 with App Router, all components are Server Components by default. You only need to add "use client" when you require browser interactivity.

The right mental model

Think of Server Components as templates that render on the server and send pure HTML to the client. The browser receives the final result, not the code to generate it.

tsx
// app/[locale]/blog/page.tsx — Server Component by default
import { getTranslations } from "next-intl/server";

export default async function BlogPage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "blog" });

  // Direct data access — no intermediate API needed
  const posts = await fetch("https://api.example.com/posts", {
    next: { revalidate: 3600 },
  }).then((res) => res.json());

  return (
    <main>
      <h1>{t("page_title")}</h1>
      {posts.map((post: { id: string; title: string }) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
        </article>
      ))}
    </main>
  );
}

Server Components vs Client Components

The key is understanding when you need each type. It's not about choosing one over the other — it's about composing both strategically.

When to use Server Components

  • Data fetching: Direct access to databases or APIs without going through public endpoints.
  • Static or semi-static content: Text, listings, layouts that don't change with user interaction.
  • SEO-critical content: Content renders as complete HTML, ideal for crawlers.
  • Heavy components: Libraries like syntax highlighters or Markdown parsers that don't need to be sent to the client.
  • Access to secrets: Server environment variables, API tokens, database credentials.

When to use Client Components

  • Interactivity: onClick, onChange, onSubmit, and any event handler.
  • Local state: useState, useReducer, useContext.
  • Browser effects: useEffect, IntersectionObserver, localStorage.
  • Third-party hooks: useTranslations (next-intl), useForm (react-hook-form).
tsx
// components/common/ThemeToggle.tsx — Client Component
"use client";

import { useState, useCallback } from "react";

export function ThemeToggle() {
  const [isDark, setIsDark] = useState(false);

  const toggle = useCallback(() => {
    setIsDark((prev) => !prev);
    document.documentElement.classList.toggle("dark");
  }, []);

  return (
    <button
      onClick={toggle}
      aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
      className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
    >
      {isDark ? "☀️" : "🌙"}
    </button>
  );
}

Advanced composition patterns

Server + Client container pattern

The most powerful RSC pattern is passing Server Components as children of Client Components. This lets you have an interactive wrapper without converting the entire tree to client code.

tsx
// components/sections/InteractiveSection.tsx
"use client";

import { useState, type ReactNode } from "react";

interface Props {
  title: string;
  children: ReactNode;
}

export function InteractiveSection({ title, children }: Props) {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <section>
      <button
        onClick={() => setIsExpanded(!isExpanded)}
        aria-expanded={isExpanded}
      >
        {title}
      </button>
      {isExpanded && children}
    </section>
  );
}

// app/[locale]/page.tsx — Server Component
import { InteractiveSection } from "@/components/sections/InteractiveSection";

export default async function Page() {
  // This fetch runs on the server
  const data = await fetchHeavyData();

  return (
    <InteractiveSection title="View details">
      {/* This content renders on the server */}
      <HeavyDataTable data={data} />
    </InteractiveSection>
  );
}

Streaming with Suspense

One of the most powerful advantages of RSC is streaming. You can send parts of the page to the browser while others are still being generated on the server.

tsx
import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>

      {/* Shows immediately */}
      <QuickStats />

      {/* Loads with streaming */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </main>
  );
}

// Each async component can take as long as it needs
async function RevenueChart() {
  const data = await getRevenueData(); // 2-3 seconds
  return <Chart data={data} />;
}

async function RecentOrders() {
  const orders = await getRecentOrders(); // 1-2 seconds
  return <OrdersTable orders={orders} />;
}

Data fetching in Server Components

Fetch with cache and revalidation

Next.js 15 extends the native fetch with caching options that integrate seamlessly with RSC.

typescript
// Static cache with hourly revalidation
const posts = await fetch("https://api.example.com/posts", {
  next: { revalidate: 3600 },
}).then((res) => res.json());

// No cache — always fresh
const user = await fetch("https://api.example.com/me", {
  cache: "no-store",
}).then((res) => res.json());

// Static cache (default for static pages)
const config = await fetch("https://api.example.com/config").then((res) =>
  res.json()
);

Direct database access

Since they run on the server, RSCs can directly access your ORM or database without exposing credentials.

typescript
// app/[locale]/projects/page.tsx
import { prisma } from "@/lib/prisma";

export default async function ProjectsPage() {
  const projects = await prisma.project.findMany({
    where: { published: true },
    orderBy: { createdAt: "desc" },
    select: {
      id: true,
      title: true,
      description: true,
      slug: true,
      image: true,
    },
  });

  return (
    <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
      {projects.map((project) => (
        <ProjectCard key={project.id} project={project} />
      ))}
    </div>
  );
}

Server Actions: mutations from the client

Server Actions are functions that run on the server but can be invoked from Client Components. They're the recommended way to handle forms and mutations.

typescript
// app/actions/contact.ts
"use server";

import { z } from "zod/v4";

const contactSchema = z.object({
  name: z.string().min(2),
  email: z.email(),
  message: z.string().min(10).max(1000),
});

export async function submitContact(formData: FormData) {
  const parsed = contactSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    return { error: "Invalid data", issues: parsed.error.issues };
  }

  // Send email, save to DB, etc.
  await sendEmail(parsed.data);

  return { success: true };
}
tsx
// components/common/ContactForm.tsx
"use client";

import { useActionState } from "react";
import { submitContact } from "@/app/actions/contact";

export function ContactForm() {
  const [state, action, isPending] = useActionState(submitContact, null);

  return (
    <form action={action}>
      <input name="name" required aria-label="Name" />
      <input name="email" type="email" required aria-label="Email" />
      <textarea name="message" required aria-label="Message" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send"}
      </button>
      {state?.error && <p role="alert">{state.error}</p>}
      {state?.success && <p role="status">Message sent successfully</p>}
    </form>
  );
}

Common mistakes and how to avoid them

Using hooks in Server Components

This is the most frequent error. React hooks (useState, useEffect, etc.) only work in Client Components.

tsx
// ❌ Error: hooks in Server Component
export default function Page() {
  const [count, setCount] = useState(0); // Error!
  return <p>{count}</p>;
}

// ✅ Correct: extract the interactive part
// components/Counter.tsx
"use client";
export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

// app/page.tsx (Server Component)
import { Counter } from "@/components/Counter";
export default function Page() {
  return <Counter />;
}

Non-serializable props

When passing props from Server to Client Components, data must be serializable. You cannot pass functions, Dates, Maps, or Sets.

tsx
// ❌ Error: function as prop
<ClientComponent onClick={() => console.log("click")} />

// ✅ Correct: move logic to Client Component
// or use Server Actions
<ClientComponent actionUrl="/api/action" />

Incorrect boundary mixing

Once a component is "use client", all its imports will also be Client Components. You cannot import a Server Component from a Client Component.

tsx
// ❌ Error: importing Server Component in Client Component
"use client";
import { ServerOnlyComponent } from "./ServerOnlyComponent"; // Becomes Client

// ✅ Correct: pass as children
"use client";
export function ClientWrapper({ children }: { children: React.ReactNode }) {
  return <div className="interactive">{children}</div>;
}

// In the parent Server Component:
<ClientWrapper>
  <ServerOnlyComponent />
</ClientWrapper>

Performance impact

Server Components drastically reduce the JavaScript sent to the client. In a real Next.js 15 project, we measured these improvements:

  • JavaScript bundle: 40-60% reduction on pages with heavy static content.
  • Time to Interactive (TTI): 1.5-2 second improvement on mobile devices.
  • Largest Contentful Paint (LCP): 0.8-1.2 second improvement thanks to streaming.
  • Hydration: Only Client Components need hydration, reducing browser workload.

Tip: Use the React DevTools Profiler panel and Chrome's Network tab to compare bundle sizes before and after migrating components to Server Components.

Migration strategy

If you're migrating from Pages Router or an app with many Client Components:

  1. Start with layouts: Layouts rarely need interactivity.
  2. Identify data components: Any component that only displays data is a RSC candidate.
  3. Extract interactivity: Separate interactive logic into small, specific Client Components.
  4. Use the children pattern: Wrap Client Components with children to keep maximum code on the server.
  5. Migrate data fetching: Move API calls from useEffect to async Server Components.

Conclusion

React Server Components aren't just a performance optimization — they're a paradigm shift in how we think about React application architecture. The key is composing Server and Client Components strategically, keeping as much logic as possible on the server.

In Next.js 15, this model is mature and the recommended way to build applications. If you're not yet taking full advantage, start by migrating your layouts and content pages — you'll see immediate improvements in performance and developer experience.

Share:

Related articles