Chapter 21

Next.js and Rendering Strategies

CSR, SSR, SSG, ISR, streaming and server components.

21. Next.js Rendering and the App Router

Next.js combines React with a routing system, server rendering, static generation, server-side data access, and deployment conventions. Its App Router makes React Server Components a central architectural boundary: components can render on the server by default, and interactive browser code is added only where needed. That changes how a page is divided, where data is read, and what gets sent to the browser.

This chapter compares rendering strategies, explains layouts and server/client boundaries, and builds a product page using server-side data, a client interaction, streamed content, and dynamic metadata.

Learning objectives

By the end of this chapter, you will be able to:

  1. Distinguish CSR, SSR, SSG, and ISR and their operational trade-offs.
  2. Explain React Server Components and the "use client" boundary in the App Router.
  3. Organize routes and shared UI with layouts, loading states, and error boundaries.
  4. Fetch data on the server and stream slower sections with Suspense.
  5. Generate route-aware metadata and Open Graph information.
  6. Reason about caching, infrastructure cost, and user experience together.

Rendering strategies

These terms describe when HTML or UI data is produced. They are complementary strategies, not mutually exclusive labels for an entire site.

Client-side rendering (CSR)

With CSR, the server returns a shell and JavaScript builds much of the page in the browser. This is useful for highly interactive private tools or interfaces that depend on browser-only APIs. The browser may initially show an empty shell or loading state, and users must download and run JavaScript before seeing data-heavy content. Search crawlers and social crawlers may have less reliable access to content if rendering is delayed or unsupported. CSR does not mean that the server is absent: APIs still supply data, and static assets still need to be delivered.

Server-side rendering (SSR)

With SSR, a server renders a response for a request. The initial document can include content immediately, and the server can use request-specific information such as authentication or locale. The cost is work on the server for each request unless the output or underlying data is safely cached. Personalized responses must not be accidentally shared between users.

Static site generation (SSG)

SSG produces HTML during a build. Static pages can be served from a CDN with low request-time compute and predictable performance. Build-time generation is a good fit for relatively stable content, documentation, marketing pages, and known product catalogues. A page cannot reflect request-specific secrets or fresh per-request data unless it later calls an API in the browser or uses another dynamic strategy.

Incremental static regeneration (ISR)

ISR combines pre-rendered pages with later regeneration. A page can be generated ahead of time and refreshed after a configured interval or explicit invalidation, depending on the framework and cache model. This is useful for content that changes but does not need to be rendered from scratch for every visitor. It introduces freshness questions: what is the accepted staleness window, what triggers revalidation, and what happens if regeneration fails?

In Next.js, individual routes and data dependencies can have different caching and rendering behavior. Framework defaults and APIs evolve across major releases, so make caching intent explicit and consult the versioned documentation for the project. Do not assume every fetch is cached or every route is static simply because a framework once had that default.

App Router conventions

The App Router uses the app/ directory. Files such as page.tsx define route UI, layout.tsx defines shared persistent UI, and special files can define loading, error, and not-found states. Folders map to route segments:

text
app/
  layout.tsx
  page.tsx
  products/
    [id]/
      page.tsx
      loading.tsx

app/layout.tsx is the root layout and must render the document's <html> and <body> elements. Nested layouts wrap pages below their segment and remain mounted during navigation where possible, preserving shared navigation state. Route groups such as (shop) organize files without adding a URL segment. Dynamic segments such as [id] capture route parameters.

A minimal root layout and route page:

tsx
// app/layout.tsx
import type { ReactNode } from "react";
import Link from "next/link";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <header><Link href="/">Field Shop</Link></header>
        <main>{children}</main>
      </body>
    </html>
  );
}
tsx
// app/products/page.tsx
import Link from "next/link";

export default function ProductsPage() {
  return (
    <>
      <h1>Products</h1>
      <Link href="/products/desk-lamp">Desk lamp</Link>
    </>
  );
}

Use next/link for ordinary internal navigation. Next.js can prefetch and transition between routes while preserving browser semantics. A layout should own stable shared elements; avoid making the root a client component just to handle one small interactive control. Place that control in a dedicated client component instead.

React Server Components and client boundaries

In the App Router, components are Server Components by default. A Server Component can read data on the server, access server-only modules, and compose markup without shipping its component implementation as browser JavaScript. It cannot use browser APIs, event handlers, or client-only React hooks such as useState.

A Client Component begins in a file marked with "use client" at the top, before imports. Its imported module graph becomes part of the client bundle. It can use state, effects, event handlers, and browser APIs. It can still be rendered to HTML as part of the initial response and hydrated in the browser; “client” identifies where its interactive code runs, not necessarily that its first pixels appear only after a browser render.

tsx
// app/products/AddToCartButton.tsx
"use client";

import { useState } from "react";

export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => setAdded(true)}>
      {added ? "Added" : "Add to cart"}
      <span className="sr-only"> product {productId}</span>
    </button>
  );
}

A Server Component can render this interactive leaf:

tsx
// app/products/[id]/page.tsx
import { AddToCartButton } from "../AddToCartButton";

export default async function ProductPage() {
  const product = await getProduct();
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </article>
  );
}

Props crossing from a Server Component to a Client Component must be serializable by React's server/client transport. Pass the minimum display and interaction data needed rather than secrets, open database connections, or arbitrary class instances. A server action can provide a server-side mutation path, but authorization and validation still belong on the server. Mark server-only modules where appropriate to prevent accidental client imports.

Keep the "use client" boundary as low in the component tree as practical. Making a whole page client-side because a single button needs a click handler increases client JavaScript and can prevent server-only capabilities from being used in that subtree. A sensible composition is a server-rendered page, static content, and small client islands for controls, forms, or live interaction.

Data fetching and cache intent

Server Components can await data directly. Fetching near the component that needs the data often makes ownership clear. Use server-side credentials only in server code, handle non-2xx responses, and validate external data at trust boundaries. Keep errors and not-found behavior deliberate:

tsx
// app/products/[id]/page.tsx
import { notFound } from "next/navigation";

type Product = { id: string; name: string; description: string };

async function getProduct(id: string): Promise<Product | null> {
  const response = await fetch(
    `${process.env.CATALOG_API_URL}/products/${encodeURIComponent(id)}`,
    { next: { revalidate: 300 } },
  );
  if (response.status === 404) return null;
  if (!response.ok) throw new Error("Product service is unavailable");
  return response.json() as Promise<Product>;
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();

  return <article><h1>{product.name}</h1><p>{product.description}</p></article>;
}

Recent Next.js versions define dynamic route params as a promise in the App Router; follow the types generated by the installed Next.js version. The next.revalidate option expresses a revalidation interval for this fetch in supported Next.js versions. If the content must always be fresh, use the version's explicit no-store/dynamic mechanism and understand the runtime cost. If it is public and cacheable, choose a finite freshness window and an invalidation path. Avoid placing user-specific authenticated responses into a shared cache unless the cache key and privacy behavior are intentionally designed.

Avoid fetching the same resource independently in several components when a shared promise, deduplicated request, or data-access function is appropriate. Model errors as part of the page experience: show a retryable error boundary for transient failures, a not-found screen for missing entities, and a useful empty state for valid but empty data.

Suspense and streaming

Streaming allows a server to send the shell and completed portions of a page before slower work is ready. React Suspense boundaries define what can be revealed progressively. A route's loading.tsx provides a segment-level loading UI; an explicit <Suspense> boundary can isolate a slower component.

tsx
// app/products/[id]/page.tsx
import { Suspense } from "react";

async function Recommendations({ productId }: { productId: string }) {
  const items = await getRecommendations(productId);
  return <RecommendationList items={items} />;
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <Suspense fallback={<p>Loading recommendations…</p>}>
        <Recommendations productId={id} />
      </Suspense>
    </article>
  );
}

The page does not await recommendations before returning its main content, so that subtree can suspend behind its fallback while the rest of the response proceeds. Place a boundary around a region whose delay is independent and acceptable. A fallback should occupy a stable, meaningful space to reduce layout shifts; a page full of nested spinners can feel slower and obscure the information architecture.

Streaming requires compatible server infrastructure and a rendering path that supports incremental delivery. A CDN, proxy, or application adapter that buffers responses can eliminate the perceived benefit. Test streaming in the actual production deployment, not only a local development server.

Dynamic metadata and social previews

The Metadata API lets a route export static metadata or implement generateMetadata when values depend on route data. Dynamic titles and descriptions help browser tabs, search snippets, and share cards. A canonical URL should represent the preferred public address, not a temporary preview origin.

tsx
// app/products/[id]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";

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

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) return { title: "Product not found" };

  return {
    title: `${product.name} | Field Shop`,
    description: product.description.slice(0, 160),
    alternates: { canonical: `https://shop.example.com/products/${id}` },
    openGraph: {
      title: product.name,
      description: product.description,
      images: [{ url: product.imageUrl, width: 1200, height: 630, alt: product.name }],
      type: "website",
    },
  };
}

export default async function ProductPage({ params }: Props) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();
  return <article><h1>{product.name}</h1><p>{product.description}</p></article>;
}

Avoid fetching the same product twice if the framework can memoize/deduplicate identical requests or if your data-access layer caches it appropriately. Check the actual request behavior and cache configuration. Metadata generation should not expose private information. Validate image URLs and ensure the generated image is publicly fetchable by social crawlers. Dynamic Open Graph image routes can generate a card from public record data, but should have timeouts, sensible fallbacks, and cache rules.

Caching, infrastructure, and cost

Caching changes both performance and correctness. Identify each layer: browser cache, CDN/static asset cache, Next.js data or route cache, application memory, and database/API cache. They have different keys, invalidation rules, and failure modes. A page that updates in one layer may remain stale in another.

Static output generally reduces request-time compute but can increase build time and deployment artifact size. Dynamic SSR consumes compute per request and can be necessary for personalization or frequently changing content. ISR and data revalidation trade a bounded freshness window for less repeated work. Client-side fetching can reduce initial server work but adds API traffic and may delay visible content. Server Components can reduce browser JavaScript and centralize access, but their server execution still has a cost.

Do not cache user-specific data under a key shared between users. Do not put sensitive credentials into NEXT_PUBLIC_ environment variables: variables with that prefix are exposed to browser bundles. Separate public configuration from server secrets, scope secrets to deployment environments, and rotate compromised credentials. A server-rendered page can still leak data if it returns private fields in the HTML or serializes them into client props.

Measure real user performance, server latency, cache hit rates, bundle size, and error rates. Optimize the bottleneck, not a framework slogan. An application with mostly static documentation may benefit from static generation; a private dashboard may need dynamic authorization and query data; a storefront can mix static marketing content, revalidated catalogues, and dynamic account features.

Make the rendering decision explicit

Choose a strategy for each route based on its data and audience rather than assigning one rendering mode to the entire application. A public article with infrequent edits can be generated at build time and refreshed when editorial content changes. A frequently updated public catalogue may use a short revalidation interval or tag-based invalidation, while a signed-in account page usually needs authorization-aware dynamic data. A search results page can render a useful initial response on the server and then use client-side state for refinements that should not require a full navigation.

For every cached response, write down what makes it safe to share, how long it may be stale, and how it is invalidated. A cache key that omits locale, tenant, or relevant query parameters can serve the wrong representation even when the underlying request succeeds. User-specific data should be scoped to the authenticated request and excluded from shared caches unless the framework and cache key are deliberately configured for that isolation. Conversely, marking every request as uncached can increase latency and compute costs without improving freshness for public content.

The deployment target matters too. Static generation can run almost anywhere that serves files, while request-time rendering requires a compatible server or serverless runtime. Streaming depends on the hosting path preserving streamed responses, and on the page having boundaries that allow useful content to arrive early. Before selecting a framework feature, confirm that the production adapter supports it and measure the deployed behavior. The best choice balances freshness, privacy, first-response latency, operational complexity, and cost; it is not necessarily the most dynamic or the most static option.

Troubleshooting and best practices

useState or window is unavailable in a component. It is likely a Server Component. Move the interactive part into a "use client" leaf, and pass only serializable props.

A tiny interaction ships a large bundle. Find the highest "use client" boundary. Move it down the tree, avoid importing server-only or heavy modules from client code, and inspect production bundle analysis.

Page content is stale or private data crosses users. Trace fetch options, route dynamic behavior, cache keys, invalidation, and authentication. Make public/private and freshness policy explicit. Test with two distinct users and after an update.

A route works with client navigation but direct requests return 404. Check the hosting adapter and routing setup. Next.js deployments need framework-aware routing; a generic static SPA rewrite is not a substitute for Next's server/runtime output.

Metadata is generic for every product. Export generateMetadata for route-derived values and ensure it uses the same validated record lookup as the page. Confirm the final response metadata and social crawler behavior.

The loading UI never appears. The data may resolve before the boundary is visible, the component may have been awaited above the boundary, or the production path may buffer the response. Put the suspenseful work inside the boundary and test the built deployment.

Initial rendering errors on browser-only code. Do not read window, localStorage, or viewport dimensions during server rendering. Move browser-dependent work to a Client Component and, when necessary, a client effect or event handler. Avoid effects for information that is already available on the server.

Exercises

Exercise 1: Convert a data-heavy details page into a server-first route

Given a product page that fetches data in useEffect, move the product request to the App Router server page, show a not-found route for missing products, and retain an interactive “save” button.

Solution

Create app/products/[id]/page.tsx as an async Server Component. Await the installed Next.js version's params type, call a server-only getProduct(id), and invoke notFound() for a null result. Render product description and price on the server. Extract the save control to SaveProductButton.tsx with "use client" at the top and a serializable product ID prop. The client button can update local pending state and call a server endpoint or server action. The endpoint/action must check the authenticated user, validate the ID, and persist the saved relationship; client state alone is not durable. Add an error boundary or mutation feedback for failures. This reduces the need for a loading-only page while preserving the specific browser interaction.

Exercise 2: Choose a rendering and freshness strategy

A public help article changes a few times per month. An account billing screen is private and must show current server data. Recommend a rendering/data strategy for each and list two cache risks to test.

Solution

The help article is a strong static or revalidated candidate. It can be pre-rendered and refreshed after a bounded interval, or invalidated when an editor publishes. Choose based on the acceptable delay between publication and visibility. The billing screen should authenticate on the server, avoid shared caching of personalized details, and retrieve current billing data using an explicit freshness strategy. A client query can refresh after interaction, but authorization and payment operations remain server-side. Test that one account's data never appears to another account and that publishing an article invalidates or refreshes all relevant cache layers. Also test behavior after an API error: a failed revalidation must not silently create a misleading permanent state. Document the staleness promise for each page.

Key takeaways

  • Rendering modes can coexist route-by-route and data-by-data; choose based on freshness, personalization, and compute.
  • App Router components are server-first; "use client" marks an interactive module boundary, not necessarily client-only initial pixels.
  • Keep interactive boundaries small, and pass serializable non-secret data across them.
  • Use layouts for persistent structure, Suspense for deliberate progressive rendering, and dynamic metadata for public routes.
  • Caching is a correctness and privacy design as much as a performance optimization.