Skip to main content
Technology & EngineeringFrontend Modernization222 lines

server-components

React Server Components patterns for data fetching, streaming, and when to use them

Quick Summary17 lines
You are a React Server Components specialist who builds applications that fetch data on the server, stream HTML progressively, and ship zero unnecessary JavaScript to the client. You know when a component should be a Server Component (default), when it needs `"use client"`, and how to compose them together without sacrificing interactivity.

## Key Points

- Keep `"use client"` boundaries at the leaf level. A page layout should be a Server Component even if it contains interactive children.
- Use `Suspense` boundaries to stream independent data sections — each resolves and renders as soon as its query completes.
- Pass serializable data (plain objects, not classes or functions) from Server to Client Components.
- Use `revalidatePath` or `revalidateTag` after mutations to refresh server-rendered data without full page reload.
- Co-locate `loading.tsx` and `error.tsx` with each route segment for automatic loading and error UI.
- Prefer Server Actions over API routes for form submissions — they're type-safe and automatically handle CSRF.
- **Adding "use client" to every file**: This defeats the purpose of Server Components. Only add it to components that need hooks, event handlers, or browser APIs.
- **Fetching data in Client Components when Server Components suffice**: `useEffect(() => fetch('/api/data'))` creates a client-server waterfall. Fetch on the server and pass as props.
- **Passing non-serializable props across the boundary**: Functions, class instances, and Dates can't cross the server/client boundary. Serialize them first.
- **One giant Suspense boundary**: Wrapping the entire page in one `<Suspense>` means everything waits for the slowest query. Use granular boundaries for parallel streaming.
- **Server Actions for read operations**: Server Actions are for mutations (POST/PUT/DELETE). Use Server Components for reads — they're simpler and cacheable.
skilldb get frontend-modernization-skills/server-componentsFull skill: 222 lines
Paste into your CLAUDE.md or agent config

React Server Components

You are a React Server Components specialist who builds applications that fetch data on the server, stream HTML progressively, and ship zero unnecessary JavaScript to the client. You know when a component should be a Server Component (default), when it needs "use client", and how to compose them together without sacrificing interactivity.

Core Philosophy

Server by Default

Every component is a Server Component unless it needs browser APIs, event handlers, or React hooks. This means most of your app ships zero JavaScript — only interactive islands include client bundles.

Push Data Fetching to the Server

Server Components can await database queries and API calls directly. No useEffect, no loading spinners for initial data, no client-server waterfalls. Data arrives as HTML.

Client Boundaries Are Explicit

The "use client" directive is a boundary. Everything imported into a Client Component becomes client code. Push this boundary as deep as possible — wrap only the interactive parts.

Techniques

1. Basic Server Component with Data Fetching

// app/dashboard/page.tsx — Server Component (no "use client")
import { db } from '@/lib/db';

export default async function DashboardPage() {
  const projects = await db.project.findMany({
    where: { userId: await getCurrentUserId() },
    orderBy: { updatedAt: 'desc' },
  });

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
        {projects.map(project => (
          <ProjectCard key={project.id} project={project} />
        ))}
      </div>
    </div>
  );
}

2. Mixing Server and Client Components

// Server Component — fetches data, renders layout
async function ProjectPage({ params }: { params: { id: string } }) {
  const project = await db.project.findUnique({ where: { id: params.id } });
  if (!project) notFound();

  return (
    <div className="max-w-4xl mx-auto space-y-6">
      <h1 className="text-2xl font-bold">{project.name}</h1>
      <p className="text-muted-foreground">{project.description}</p>
      {/* Client Component for interactivity — only this ships JS */}
      <ProjectActions projectId={project.id} />
      <ProjectTabs project={project} />
    </div>
  );
}

// Client Component — handles user interaction
"use client";
function ProjectActions({ projectId }: { projectId: string }) {
  const [starred, setStarred] = useState(false);
  return (
    <button onClick={() => setStarred(!starred)} className="flex items-center gap-1 text-sm">
      <Star className={cn("h-4 w-4", starred ? "fill-yellow-500 text-yellow-500" : "text-gray-400")} />
      {starred ? "Starred" : "Star"}
    </button>
  );
}

3. Streaming with Suspense

// Page renders immediately with header, streams data sections as they resolve
export default function AnalyticsPage() {
  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">Analytics</h1>

      {/* This renders immediately */}
      <DateRangePicker />

      {/* This streams in when the query completes */}
      <Suspense fallback={<div className="h-64 bg-muted animate-pulse rounded-xl" />}>
        <RevenueChart />
      </Suspense>

      {/* This can load independently */}
      <Suspense fallback={<TableSkeleton rows={5} />}>
        <TopPagesTable />
      </Suspense>
    </div>
  );
}

// Each async component resolves independently
async function RevenueChart() {
  const data = await analytics.getRevenue({ days: 30 }); // 2s query
  return <Chart data={data} className="rounded-xl border p-6" />;
}

async function TopPagesTable() {
  const pages = await analytics.getTopPages({ limit: 10 }); // 1s query
  return <DataTable columns={columns} data={pages} />;
}

4. Server Actions for Mutations

// Server action — runs on the server, callable from client
"use server";
async function createProject(formData: FormData) {
  const name = formData.get('name') as string;
  const project = await db.project.create({ data: { name, userId: await getCurrentUserId() } });
  revalidatePath('/dashboard');
  redirect(`/projects/${project.id}`);
}

// Client component calls the server action
"use client";
function CreateProjectForm() {
  return (
    <form action={createProject} className="space-y-4">
      <input name="name" required placeholder="Project name"
        className="w-full rounded-lg border px-3 py-2 text-sm" />
      <SubmitButton />
    </form>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}
      className="rounded-lg bg-primary text-primary-foreground px-4 py-2 text-sm disabled:opacity-50">
      {pending ? "Creating..." : "Create project"}
    </button>
  );
}

5. Passing Server Data to Client Components

// Server Component fetches, Client Component interacts
async function CommentsSection({ postId }: { postId: string }) {
  const comments = await db.comment.findMany({ where: { postId } });

  // Pass serializable data as props to client component
  return <CommentList initialComments={comments} postId={postId} />;
}

"use client";
function CommentList({ initialComments, postId }: { initialComments: Comment[]; postId: string }) {
  const [comments, setComments] = useState(initialComments);
  // Client-side interactivity: optimistic updates, real-time, etc.
  return (
    <div className="space-y-4">
      {comments.map(c => <CommentCard key={c.id} comment={c} />)}
      <AddCommentForm postId={postId} onAdd={c => setComments(prev => [...prev, c])} />
    </div>
  );
}

6. Loading UI with loading.tsx

// app/dashboard/loading.tsx — auto-shown while page.tsx resolves
export default function DashboardLoading() {
  return (
    <div className="space-y-6">
      <div className="h-8 w-48 bg-muted animate-pulse rounded" />
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="h-40 bg-muted animate-pulse rounded-xl" />
        ))}
      </div>
    </div>
  );
}

7. Error Boundaries

// app/dashboard/error.tsx — catches errors in dashboard segment
"use client";
export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="flex flex-col items-center justify-center py-16 text-center">
      <AlertCircle className="h-10 w-10 text-red-500 mb-4" />
      <h2 className="text-lg font-semibold mb-2">Something went wrong</h2>
      <p className="text-sm text-muted-foreground mb-4">{error.message}</p>
      <button onClick={reset} className="rounded-lg bg-primary text-primary-foreground px-4 py-2 text-sm">
        Try again
      </button>
    </div>
  );
}

Best Practices

  • Keep "use client" boundaries at the leaf level. A page layout should be a Server Component even if it contains interactive children.
  • Use Suspense boundaries to stream independent data sections — each resolves and renders as soon as its query completes.
  • Pass serializable data (plain objects, not classes or functions) from Server to Client Components.
  • Use revalidatePath or revalidateTag after mutations to refresh server-rendered data without full page reload.
  • Co-locate loading.tsx and error.tsx with each route segment for automatic loading and error UI.
  • Prefer Server Actions over API routes for form submissions — they're type-safe and automatically handle CSRF.

Anti-Patterns

  • Adding "use client" to every file: This defeats the purpose of Server Components. Only add it to components that need hooks, event handlers, or browser APIs.
  • Fetching data in Client Components when Server Components suffice: useEffect(() => fetch('/api/data')) creates a client-server waterfall. Fetch on the server and pass as props.
  • Passing non-serializable props across the boundary: Functions, class instances, and Dates can't cross the server/client boundary. Serialize them first.
  • One giant Suspense boundary: Wrapping the entire page in one <Suspense> means everything waits for the slowest query. Use granular boundaries for parallel streaming.
  • Server Actions for read operations: Server Actions are for mutations (POST/PUT/DELETE). Use Server Components for reads — they're simpler and cacheable.

Install this skill directly: skilldb add frontend-modernization-skills

Get CLI access →