← Back to Index

Chapter 3: Rendering Modes

Server Components, SSR/SSG & Streaming

1. Server Components & Client Components

Under the App Router (app/), Next.js 15 treats all components as Server Components by default: they are rendered on the server first, and their results participate in page assembly as RSC Payload.

The table below summarizes the capability differences (with React experience, think of Server Components as "a React tree that runs on the server by default"):

Capability / Limitation Server Component Client Component
useState / useReducer ❌ Not supported ✅ Supported
useEffect / Browser events ❌ Not supported ✅ Supported
Direct database / file access ✅ Supported ❌ Should not be exposed on browser side
async components (top-level await) ✅ Supported ❌ Not supported (requires child Server components or data layer splitting)
Included in client bundle ❌ Not bundled to browser by default ✅ Will be bundled

Compared to pure React (CRA / Vite SPA)

In traditional client-side React apps, almost all components ultimately execute in the browser. Next.js App Router changes the default rendering location to the server — only modules explicitly marked with "use client" are bundled and hydrated as "client components", which is a fundamental difference in performance and data boundaries.

💡 Key Point

Prefer Server Components; only switch a module to Client Component and add "use client" when you need interactivity (local state, events, browser APIs, certain client-only third-party libraries).

2. "use client" Directive

Example: Counter with state

"use client";

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button type="button" onClick={() => setCount((c) => c + 1)}>
      点击次数:{count}
    </button>
  );
}

Example: Subscribing to browser environment with useEffect

"use client";

import { useEffect, useState } from "react";

export function WindowWidth() {
  const [w, setW] = useState<number | null>(null);
  useEffect(() => {
    const onResize = () => setW(window.innerWidth);
    onResize();
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return <p className="text-sm text-gray-600">视口宽度:{w ?? "…"}</p>;
}

💡 Boundary Rules

"use client" defines the module boundary between Server and Client. Client Components cannot directly import Server Components as regular sub-modules (this would force server-side modules into the client bundle). The common approach is: compose in a Server page — pass server-rendered nodes as children or props to Client components, letting the client handle only the interactive shell.

3. Advantages of Server Components

Example: Fetching data directly with async in a Server Component (Next.js 15 has built-in cache semantics for fetch, covered in the ISR section below):

// app/posts/page.tsx — 默认为 Server Component,无需 "use client"

type Post = { id: string; title: string };

export default async function PostsPage() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5", {
    cache: "force-cache",
  });
  const posts: Post[] = await res.json();

  return (
    <ul className="space-y-2">
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

💡 Tip

When you need to make an item in the list clickable or bookmarkable, extract "that small piece" into a Client Component instead of adding "use client" to the entire page.

4. Static Rendering (SSG) & Dynamic Rendering (SSR)

Next.js 15 automatically infers at build or request time whether a route is "static" or "dynamic". Routes that don't access request-specific APIs are more likely to be statically rendered; once a request-dependent API is used during rendering, the route falls into dynamic rendering.

For dynamic segments (e.g., app/blog/[slug]/page.tsx), you can use generateStaticParams to pre-generate a set of static paths at build time, while remaining paths can still be dynamically rendered on demand:

// app/blog/[slug]/page.tsx

type Props = { params: Promise<{ slug: string }> };

export async function generateStaticParams() {
  return [{ slug: "hello" }, { slug: "next-15" }];
}

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  return (
    <article>
      <h1 className="text-2xl font-bold">文章:{slug}</h1>
      <p className="text-gray-600 mt-2">构建时预生成的 slug 会走静态路径;其他 slug 仍可运行时解析。</p>
    </article>
  );
}

Note: In Next.js 15, route params and searchParams in page props are Promises that must be awaited inside async components before use.

5. ISR (Incremental Static Regeneration)

ISR allows static pages to automatically refresh in the background after a period of time, balancing CDN caching with content timeliness. This can be configured via fetch's next.revalidate, or by exporting revalidate from page.tsx / layout.tsx for segment-level default behavior.

Page-level: export const revalidate = 60

// app/dashboard/page.tsx

export const revalidate = 60; // 秒:该路由段默认 ISR 周期

export default async function DashboardPage() {
  return <p>此段每 60 秒可在后台再生成一次(具体以部署环境缓存策略为准)。</p>;
}

Fetch-level revalidate

const res = await fetch("https://api.example.com/items", {
  next: { revalidate: 120 },
});

On-demand invalidation: revalidatePath / revalidateTag

In Server Actions or Route Handlers, you can precisely refresh the cache after data changes:

import { revalidatePath, revalidateTag } from "next/cache";

export async function POST() {
  revalidatePath("/posts");
  revalidateTag("posts-list");
  return Response.json({ ok: true });
}

Use revalidateTag with fetch(..., { next: { tags: ['posts-list'] } }) to invalidate only related data rather than the entire site.

6. Streaming & Suspense

React 18+ Streaming allows the server to send HTML in chunks. Combined with Suspense, "slow" parts don't block "fast" parts. In Next.js App Router, loading.tsx within the same route automatically wraps that segment's Suspense boundary, providing instant loading placeholders.

// app/streaming-demo/page.tsx
import { Suspense } from "react";

async function SlowBlock() {
  await new Promise((r) => setTimeout(r, 2000));
  return <p className="text-green-700">慢内容已就绪</p>;
}

export default function StreamingDemoPage() {
  return (
    <div className="space-y-4">
      <p>首屏立即可见。</p>
      <Suspense fallback={<p className="animate-pulse text-gray-500">加载中…</p>}>
        <SlowBlock />
      </Suspense>
    </div>
  );
}

💡 UX Advantage

Faster first paint: Critical UI reaches the browser first; heavy data or slow APIs can progressively render within Suspense boundaries, avoiding full-page blank loading screens.

7. Composition Patterns Best Practices

Recommended structure: Keep pages and data fetching in Server Components as much as possible; extract interactive UI (forms, dialogs, rich interactive widgets) into Client Components; pass server-rendered fragments into the client "shell" via children or props.

Diagram: Layout (Server)InteractivePanel (Client) → with slot content as DataDisplay (Server, as children):

// app/demo/page.tsx — Server Component
import { InteractivePanel } from "@/components/InteractivePanel";
import { DataDisplay } from "@/components/DataDisplay";

export default async function DemoPage() {
  const data = await Promise.resolve({ title: "服务端数据" });
  return (
    <InteractivePanel title="可交互面板">
      <DataDisplay data={data} />
    </InteractivePanel>
  );
}
// components/InteractivePanel.tsx — Client
"use client";

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

export function InteractivePanel({
  title,
  children,
}: {
  title: string;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(true);
  return (
    <section className="rounded border p-4">
      <button type="button" onClick={() => setOpen((o) => !o)}>
        {open ? "收起" : "展开"} {title}
      </button>
      {open ? <div className="mt-3">{children}</div> : null}
    </section>
  );
}
// components/DataDisplay.tsx — Server Component(无 "use client")
type Props = { data: { title: string } };

export function DataDisplay({ data }: Props) {
  return <p className="text-gray-800">{data.title}</p>;
}

Here DataDisplay is first rendered on the server as RSC output, then passed as children to the client-side InteractivePanel, following the officially recommended composition pattern.

8. Chapter Summary

Server by Default

Components in App Router are Server Components by default; use "use client" only when state, events, or browser APIs are needed.

Boundaries & Composition

"use client" at file top; client modules should not directly import server modules — pass server-rendered results via children instead.

Static vs Dynamic

Next auto-determines; cookies / headers / request-dependent searchParams often trigger dynamic rendering; generateStaticParams can pre-generate static pages for dynamic routes.

ISR & Invalidation

Use revalidate, fetch(..., { next: { revalidate } }) to control incremental regeneration; use revalidatePath / revalidateTag for on-demand refresh.

Streaming

loading.tsx and Suspense enable streaming output and progressive loading, improving first paint and interaction wait times.