Field note / nextjs

publishedupdated 2026-05-01#nextjs#server-components#rsc#server-actions#use-client

React Server Components

RSC model — server-only rendering, zero JS bundle, composition patterns, the 'use client' boundary, Server Actions, and common pitfalls.

Overview

React Server Components (RSC) fundamentally change the rendering model: components run on the server and send a serialized React tree (not HTML, not JS) to the client. The client hydrates only Client Components. The result: zero JavaScript sent to the browser for Server Components, enabling direct database/filesystem access without an API layer, while keeping full interactivity where needed.


Architecture

RSC RENDERING PIPELINE
════════════════════════════════════════════════════════

  Server                              Client
  ──────────────────────────────────  ──────────────────────────────────
  1. React renders Server Components  5. Browser receives HTML (fast paint)
     to RSC Payload (serialized        6. React reconciles RSC payload
     React tree + data)                   with Client Component hydration
  2. Serializes Client Component      7. Event handlers attached
     placeholders + their props          UI is interactive
  3. Renders HTML from RSC payload
  4. Sends HTML + RSC Payload
     + Client Component JS bundles

  SERVER COMPONENT                    CLIENT COMPONENT
  ✓ async function                    ✓ useState, useEffect, useRef
  ✓ await fetch/db/fs directly        ✓ event handlers (onClick, etc.)
  ✓ no JS bundle to browser           ✓ browser APIs (window, localStorage)
  ✗ no hooks (useState, etc.)         ✗ cannot be async
  ✗ no event handlers                 ✗ no server-only imports
  ✗ no window/localStorage            ✓ runs on server too (SSR initial HTML)

COMPONENT TREE SPLIT
════════════════════════════════════════════════════════

  app/                                   Server   Client
  └── page.tsx (Server)                    ✓
      └── ProductList (Server)             ✓
          └── ProductCard (Server)         ✓
              └── AddToCart 'use client'   ✗        ✓
                  │
                  ─── everything BELOW AddToCart is also client
                      (unless passed as children from server)

  PASSING SERVER COMPONENTS INTO CLIENT COMPONENTS:
  // This works! Server renders Nav, passes as children to Shell
  <ClientShell>
    <ServerNav />      ← runs on server, HTML result passed to ClientShell
  </ClientShell>

  // This does NOT work:
  // 'use client'
  // import { ServerComponent } from './server-component'  ← ERROR

Technical Implementation

Data Fetching in Server Components

// app/products/page.tsx — Server Component: direct DB access, no API route needed
import { db } from '@/lib/db';  // server-only (Prisma, postgres, etc.)

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: { q?: string; page?: string };
}) {
  // Direct database query — no useEffect, no API call, no loading state
  const products = await db.product.findMany({
    where: searchParams.q
      ? { name: { contains: searchParams.q, mode: 'insensitive' } }
      : undefined,
    skip: ((parseInt(searchParams.page ?? '1') - 1) * 20),
    take: 20,
    orderBy: { updatedAt: 'desc' },
  });

  return (
    <div>
      <SearchBar />            {/* Client Component — needs interactivity */}
      {products.map(p => (
        <ProductCard key={p.id} product={p}>
          {/* AddToCart is a Client Component — passed as child */}
          <AddToCartButton productId={p.id} />
        </ProductCard>
      ))}
    </div>
  );
}

// server-only package: throw at import if used in client bundle
import 'server-only';  // add to db.ts, auth.ts, etc. to guard against accidents

Server Actions for Mutations

// Server Actions: async functions that run on the server, called from client
// No need for an API route for simple mutations

'use server';  // marks all exports as Server Actions (or per-function in mixed files)

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { z } from 'zod';

const CreateOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().min(1).max(100),
});

export async function createOrder(formData: FormData) {
  const parsed = CreateOrderSchema.safeParse({
    productId: formData.get('productId'),
    quantity: Number(formData.get('quantity')),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  const order = await db.order.create({
    data: {
      productId: parsed.data.productId,
      quantity: parsed.data.quantity,
      userId: await getCurrentUserId(),
    },
  });

  revalidatePath('/orders');        // invalidate cached orders list
  redirect(`/orders/${order.id}`);  // redirect after mutation
}

// Client Component — form submission calls Server Action
'use client';
import { createOrder } from './actions';

export function OrderForm({ productId }: { productId: string }) {
  return (
    <form action={createOrder}>
      <input type="hidden" name="productId" value={productId} />
      <input type="number" name="quantity" defaultValue={1} min={1} />
      <button type="submit">Order</button>
    </form>
  );
}

server-only Guard for Sensitive Modules

// lib/db.ts — add server-only to prevent accidental client import
import 'server-only';                // throws if imported in client bundle
import { PrismaClient } from '@prisma/client';

// Singleton pattern for Prisma in development (avoid new clients on hot reload)
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

// lib/auth.ts — also server-only
import 'server-only';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';

export async function getCurrentUserId(): Promise<string> {
  const token = cookies().get('session')?.value;
  if (!token) throw new Error('Not authenticated');
  const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET));
  return payload.sub!;
}

Interview Preparation

Q: Why can't you import a Server Component inside a Client Component?

Client Components ship JavaScript to the browser. When Webpack/Turbopack builds the client bundle, it traverses all imports from Client Component files. If a Server Component were importable from a Client Component, the bundler would need to include it in the client bundle — but Server Components may import server-only modules (database clients, filesystem utilities) that cannot run in the browser. Instead, the pattern is to pass Server Components as children or other props into Client Components — the Server Component renders on the server and its HTML output is passed to the Client Component as a serialized React node.

Q: What are Server Actions and when would you use them instead of API routes?

Server Actions are async functions marked with 'use server' that run on the server when invoked from a Client Component. They're built into the React/Next.js framework: no manual HTTP endpoint, no fetch() call in the client, no CORS concerns. Use them for form submissions, mutations that need the current session/cookies, and operations that should only run on the server. Use API routes when: you need to support external clients (mobile apps, third-party integrations), you need fine-grained control over HTTP method/headers/status codes, or you're building a public API. Server Actions are serialized POST requests under the hood — they're not websockets, not streaming, and add a network round-trip.

Q: What is the server-only package and why use it?

The server-only npm package contains a single export that throws an error when imported in a browser context. Adding import 'server-only' to sensitive modules (database clients, auth utilities, secret reading functions) causes the build to fail with a clear error if that module is accidentally imported in a Client Component. Without this guard, a developer might inadvertently import a module that reads process.env.DATABASE_URL into a Client Component — the build might succeed, but the environment variable could be leaked into the client bundle. server-only catches this at build time rather than at runtime.


Learning Resources