Skip to main content
Engineering Reference · 2026

React Rendering Patterns in 2026

A production-grade handbook covering every major rendering strategy in the React ecosystem: CSR, SSR, SSG, ISR, React Server Components, hydration, streaming, partial prerendering, and edge rendering. Architecture trade-offs, decision frameworks, and real-world patterns.

~50 min readIntermediate to AdvancedReact 19 · Next.js 15 · 2026
01 / Introduction

Why Rendering Strategy Is an Architectural Decision

Every React application makes a rendering choice. The question is whether that choice is deliberate or accidental. When a team picks Vite for a new project, they have chosen Client-Side Rendering for the entire application. When they reach for Next.js, they have access to a spectrum of strategies and must choose intentionally for each page and each data boundary.

The rendering strategy you choose shapes Largest Contentful Paint (LCP), Time to First Byte (TTFB), search engine indexability, JavaScript bundle size, server infrastructure cost, and the mental model your team maintains when building features. These are not implementation details: they are architectural properties that compound across the lifetime of a product.

The core insight of 2026: rendering strategy is no longer a per-application choice. Modern frameworks let you choose a strategy per route, per component, and even per data boundary within a single page. The skill is knowing which strategy fits which piece of your UI.

The Rendering Spectrum

React rendering patterns form a spectrum from fully static to fully dynamic:

  • Static Site Generation (SSG): HTML built at deploy time, served from a CDN. Zero runtime cost, maximum performance, but data is frozen at build time.
  • Incremental Static Regeneration (ISR): SSG pages that regenerate in the background on a schedule or on demand. The best of SSG with controlled freshness.
  • React Server Components (RSC): Components that run only on the server, ship zero JavaScript to the browser, and can access databases and secrets directly.
  • Server-Side Rendering (SSR): Full HTML generated on every request. Always fresh, but always costs server time.
  • Streaming SSR: SSR where chunks of HTML stream to the browser as they become ready, driven by Suspense boundaries.
  • Partial Prerendering (PPR): A static shell served instantly from CDN with dynamic holes that stream in. Both fast and fresh.
  • Client-Side Rendering (CSR): All rendering in the browser. Simplest to host but with real performance and SEO costs for public content.
02 / Browser Rendering Model

How the Browser Renders and Where React Fits In

Understanding React rendering patterns requires understanding the browser's critical rendering path first. Every byte of HTML, CSS, and JavaScript flows through a pipeline before the user sees anything on screen.

The Critical Rendering Path

  1. HTML parsing: The browser receives bytes and parses them into a DOM tree. External resources (CSS, JS with src) block or interrupt this process depending on their placement and attributes.
  2. CSSOM construction: CSS is parsed into the CSS Object Model. Rendering is blocked until the CSSOM is complete because the browser cannot determine computed styles without it.
  3. Render tree: DOM + CSSOM merge into the render tree, which contains only visible nodes with their computed styles.
  4. Layout: The browser calculates the exact position and size of every element in the render tree.
  5. Paint and composite: Pixels are drawn to the screen in layers and composited into the final image.

JavaScript that is not deferred or async pauses HTML parsing when the browser encounters the <script> tag. This is why render-blocking scripts placed in <head> without defer orasync delay everything that comes after them.

Where React Enters the Pipeline

React's entry point differs based on the rendering pattern:

  • CSR: React runs after the browser parses and executes the JavaScript bundle. The HTML arriving from the server is essentially empty. React creates the DOM from scratch in the browser.
  • SSR/SSG/ISR: The server sends fully-rendered HTML. The browser parses and paints this immediately. React then runs, compares its virtual representation to the existing DOM, and attaches event listeners without repainting (this is hydration).
  • RSC: Server Components render on the server but produce a special RSC payload (not HTML). Client Components hydrate normally; Server Components produce no JavaScript in the browser.
  • Streaming: The server sends HTML in chunks as Suspense boundaries resolve. The browser renders each chunk immediately.
LCP and rendering strategy: Largest Contentful Paint is measured from navigation start to when the largest visible element is painted. SSG/ISR pages with CDN delivery typically achieve LCP under 1.5s. CSR pages routinely see LCP of 3-6s on average mobile hardware because the content only appears after the JS bundle downloads, parses, and executes.
03 / Client-Side Rendering

Client-Side Rendering (CSR)

Client-Side Rendering is the default model for plain React applications. The server delivers a minimal HTML shell containing a <div id="root">and script tags. The browser downloads the JavaScript bundle, executes it, and React renders the entire UI in the browser.

The CSR Timeline

  1. Browser requests the URL. Server responds with an HTML shell (fast, nearly instant).
  2. Browser parses HTML and discovers script tags. Downloads JS bundle.
  3. JS bundle executes. React initializes. ReactDOM.createRoot is called.
  4. React renders the component tree. Data fetches are triggered (useEffect).
  5. API responses arrive. State updates trigger re-renders. Content becomes visible.
  6. The page is now interactive. LCP is measured here: often 3-6 seconds on mobile.
tsxCSR entry point (Vite + React)
// src/main.tsx -- pure client-side rendering
import { createRoot } from "react-dom/client";
import { App } from "./App";

// At this point, index.html has only <div id="root"></div>
// Nothing is visible until React runs and fetches data
createRoot(document.getElementById("root")!).render(<App />);

// src/App.tsx -- data is fetched client-side
import { useQuery } from "@tanstack/react-query";

export function App() {
  const { data, isPending } = useQuery({
    queryKey: ["products"],
    queryFn: () => fetch("/api/products").then((r) => r.json()),
  });

  // Users see a spinner until the API responds -- LCP is delayed
  if (isPending) return <div>Loading...</div>;
  return <ProductGrid products={data} />;
}

When CSR is the Right Choice

Good fit for CSR

  • Internal dashboards and admin tools behind authentication
  • Data-heavy SPAs where content changes on every interaction
  • Apps embedded inside Electron or mobile webviews
  • Tooling apps where SEO is irrelevant and users expect a loading state
  • Prototypes and MVPs where simplicity outweighs performance requirements

Poor fit for CSR

  • Public marketing pages where LCP determines conversion
  • E-commerce product pages that need search engine indexing
  • News or blog content where Google must see the text immediately
  • Any page where Core Web Vitals are a KPI
  • Pages with users on slow connections or low-end devices
The SEO misconception: Googlebot does eventually execute JavaScript and index CSR content, but with a delay of hours to days. For time-sensitive content (news, prices, inventory), this lag can mean search engines miss updates entirely. For pages where ranking matters, CSR is a significant disadvantage.
04 / Server-Side Rendering

Server-Side Rendering (SSR)

Server-Side Rendering generates a fully-rendered HTML page on the server for every incoming request. The browser receives real content immediately, paints it, and then React hydrates the page to attach event handlers. The user sees content before JavaScript finishes executing.

The SSR Timeline

  1. Browser requests the URL. Server receives the request.
  2. Server fetches data (database query, API call, cache lookup).
  3. Server renders the React component tree to HTML using renderToString or renderToPipeableStream.
  4. Server sends complete HTML. Browser paints content immediately. TTFB reflects server render time.
  5. Browser downloads the JS bundle in parallel with parsing HTML.
  6. React hydrates: attaches event handlers to the server-rendered DOM without repainting.
  7. Page is fully interactive. Time to Interactive (TTI) follows LCP by the hydration window.
tsxSSR with async Server Component in Next.js 15
// app/products/[id]/page.tsx
// This is a Server Component -- runs only on the server, per request

interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}

async function getProduct(id: string): Promise<Product> {
  // Direct database or API call -- runs server-side, never in browser
  const res = await fetch(`https://api.internal/products/${id}`, {
    cache: "no-store",  // always fetch fresh data per request
  });
  if (!res.ok) throw new Error("Product not found");
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);  // awaited on the server

  // This component renders to HTML on the server for every request
  return (
    <main>
      <h1>{product.name}</h1>
      <p>Price: ${product.price}</p>
      <p>In stock: {product.stock > 0 ? "Yes" : "No"}</p>
      <AddToCartButton productId={product.id} />
    </main>
  );
}

SSR Trade-offs

  • TTFB increases with server render time. A complex page that queries multiple data sources before rendering will have a higher TTFB than a static page. Use caching aggressively at the data layer.
  • TTI lags behind LCP. The browser sees content quickly but the page is not interactive until React hydrates. For heavy JS bundles, this gap can be 2-4 seconds. Streaming SSR closes this gap.
  • Server cost scales with traffic. Unlike SSG (which scales to any traffic level from CDN), SSR requires server compute for every request. Plan for scaling.
  • Data is always fresh. SSR is the right choice when content changes with every request (personalized pages, real-time inventory, live data).
In Next.js 15: any async Server Component that callsfetch without a cache policy, reads request-time data (headers, cookies), or calls dynamic functions (headers(),cookies()) is automatically SSR. The router handles the distinction transparently.
05 / Static Site Generation

Static Site Generation (SSG)

Static Site Generation pre-renders pages at build time and serves them as static HTML files from a CDN. There is no server rendering at request time: the browser receives a complete, pre-built HTML file the moment it makes a request. This is the fastest possible way to deliver content.

tsxSSG in Next.js App Router
// app/blog/[slug]/page.tsx

// generateStaticParams tells Next.js which dynamic routes to pre-render
export async function generateStaticParams() {
  const posts = await fetch("https://api.example.com/posts").then((r) => r.json());
  return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}

// The page component fetches data at build time
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) =>
    r.json()
  );

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.publishedAt}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

// app/docs/page.tsx -- simplest SSG: no dynamic params, no request-time data
export const dynamic = "force-static";

export default function DocsPage() {
  return <div>This page is pre-rendered at build time</div>;
}

SSG Strengths and Constraints

SSG strengths

  • Instant TTFB: CDN serves pre-built files with no server computation
  • Perfect LCP scores when content is in the HTML
  • Zero runtime server cost: static file hosting only
  • Trivially scalable: CDN handles any traffic spike
  • Excellent SEO: search engines immediately see all content

SSG constraints

  • Data is frozen at build time: stale content until next deploy
  • Large site builds can take minutes or hours (100k+ pages)
  • Not suitable for personalized or user-specific content
  • Requires a rebuild cycle to update content
  • Dynamic routes need all params known at build time

SSG is ideal for content that changes infrequently: documentation, marketing pages, blog posts, and any page where the data does not vary per user or per request. When data changes on a schedule, ISR gives you SSG performance with controlled freshness.

06 / Incremental Static Regeneration

Incremental Static Regeneration (ISR)

Incremental Static Regeneration extends SSG with controlled freshness. Pages are pre-rendered at build time (like SSG), but after a configured time window, the next request triggers a background rebuild. The current visitor still sees the cached page; the new version is ready for the next visitor. This is the stale-while-revalidate model applied to full pages.

tsxTime-based ISR with revalidate
// app/products/page.tsx

// revalidate tells Next.js to rebuild this page every 60 seconds (at most)
export const revalidate = 60;

async function getProducts() {
  const res = await fetch("https://api.example.com/products", {
    next: { revalidate: 60 },  // Next.js Data Cache also revalidates
  });
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}
tsOn-demand ISR with revalidatePath and revalidateTag
// app/api/revalidate/route.ts
// Called by a CMS webhook when content changes
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get("secret");

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: "Invalid token" }, { status: 401 });
  }

  const { path, tag } = await request.json();

  if (path) {
    revalidatePath(path);  // Purge and rebuild a specific route
  }
  if (tag) {
    revalidateTag(tag);  // Purge all fetches tagged with this string
  }

  return NextResponse.json({ revalidated: true });
}

ISR vs SSG vs SSR Decision

  • Use SSG when content never changes between deploys (docs, legal pages, evergreen marketing content).
  • Use ISR with time-based revalidation when content changes on a predictable schedule (product listings, news feeds, pricing).
  • Use ISR with on-demand revalidation when content changes unpredictably but you have a content management system that can trigger webhooks.
  • Use SSR when content must be fresh on every request and personalized to the user (account pages, live inventory, dynamic prices).
07 / React Server Components

React Server Components (RSC)

React Server Components are components that execute exclusively on the server. They are never hydrated, never send their module code to the browser, and can access server-only resources directly: databases, file systems, environment secrets, and internal APIs. This is not SSR: SSR sends HTML to the browser and then hydrates it; RSC sends a serializable component description that the React runtime on the client merges into the existing UI.

The zero-bundle guarantee:Server Component code is never shipped to the browser. A Server Component can import a 50 MB database client library and that library will not add a single byte to the client bundle. This is the primary performance advantage over traditional SSR.

RSC Capabilities and Constraints

Server Components can

  • Be async functions that await data directly
  • Import server-only packages (ORMs, database drivers, sdks)
  • Read environment variables and secrets
  • Access the file system
  • Render Client Components as children
  • Pass serializable props to Client Components

Server Components cannot

  • Use React hooks (useState, useEffect, etc.)
  • Use browser APIs (window, document, localStorage)
  • Add event handlers (onClick, onChange)
  • Use Context from the client side
  • Pass non-serializable values (functions) to Client Components as props
tsxServer Component with direct database access
// app/orders/page.tsx -- Server Component (the default in App Router)
import { db } from "@/lib/db";  // ORM or database client -- never sent to browser
import { auth } from "@/lib/auth";

export default async function OrdersPage() {
  const session = await auth();  // Server-only auth check

  // Direct database query -- no HTTP round trip, no API endpoint needed
  const orders = await db.order.findMany({
    where: { userId: session.user.id },
    orderBy: { createdAt: "desc" },
    take: 20,
  });

  return (
    <section>
      <h1>Your Orders</h1>
      <OrderList orders={orders} />    {/* Server Component child */}
      <CancelButton orderId={orders[0]?.id} />  {/* "use client" component */}
    </section>
  );
}
tsxThe use client boundary
// components/CancelButton.tsx
"use client";  // Everything below this line runs in the browser

import { useState } from "react";
import { cancelOrder } from "@/actions/orders";  // Server Action

export function CancelButton({ orderId }: { orderId: string }) {
  const [loading, setLoading] = useState(false);

  async function handleCancel() {
    setLoading(true);
    await cancelOrder(orderId);  // Calls the server, not just client-side code
    setLoading(false);
  }

  return (
    <button onClick={handleCancel} disabled={loading}>
      {loading ? "Cancelling..." : "Cancel Order"}
    </button>
  );
}

RSC vs SSR: A Critical Distinction

Both RSC and SSR run code on the server, but their output and purpose differ fundamentally. SSR produces HTML: the full string representation of a page sent to the browser. RSC produces a React component tree in a serialized format (the RSC payload), which the React runtime merges into the existing UI without a full page reload. RSC enables partial re-renders of Server Components after navigation, something traditional SSR cannot do.

08 / Hydration

Hydration: Connecting the Server to the Client

Hydration is the process where React takes over server-rendered HTML by attaching event listeners and reconciling the server-produced DOM with React's virtual representation. When hydration works correctly, the browser paints the server-rendered content immediately and the page becomes interactive without any visible repaint.

How React Hydrates

  1. The browser receives and renders the server-produced HTML. Users see real content.
  2. The JavaScript bundle downloads and executes. React initializes.
  3. hydrateRoot walks the existing DOM and matches it against the component tree React would have rendered. This is the reconciliation pass.
  4. React attaches event listeners at the matched positions without touching the DOM. The painted pixels are never replaced.
  5. The page is now interactive. The transition from server HTML to interactive React app is invisible to the user.

Hydration Mismatches

A hydration mismatch occurs when the HTML React generates during hydration does not match the HTML the server produced. React throws a warning (and in development mode, it fully re-renders the mismatched subtree, which can cause a visible flash).

tsxCommon hydration mismatch causes and fixes
// PROBLEM 1: Date/time values differ between server and client render time
// Server renders at 12:00:00, client hydrates 200ms later -- mismatch

// Bad:
export function Timestamp() {
  return <time>{new Date().toISOString()}</time>;
}

// Fix: use suppressHydrationWarning for values that will always differ
export function Timestamp() {
  return <time suppressHydrationWarning>{new Date().toISOString()}</time>;
}

// PROBLEM 2: Reading localStorage or window in a Server Component
// Bad:
function ThemeProvider() {
  const saved = localStorage.getItem("theme") ?? "dark";  // ReferenceError on server
  return <div data-theme={saved}>{children}</div>;
}

// Fix: gate with typeof window or use a useEffect + useState pattern
"use client";
import { useState, useEffect } from "react";

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState("dark");  // matches server default

  useEffect(() => {
    // Runs only in browser, after hydration -- no mismatch
    const saved = localStorage.getItem("theme");
    if (saved) setTheme(saved);
  }, []);

  return <div data-theme={theme}>{children}</div>;
}

// PROBLEM 3: Math.random() or crypto.randomUUID() in component body
// Bad:
function Card() {
  const id = crypto.randomUUID();  // different on server and client
  return <div id={id}>...</div>;
}

// Fix: generate the ID outside the component or use useId()
import { useId } from "react";
function Card() {
  const id = useId();  // React guarantees the same ID on server and client
  return <div id={id}>...</div>;
}

Selective and Concurrent Hydration

React 18 introduced concurrent hydration. Instead of hydrating the entire page in one blocking pass, React can interrupt hydration to handle urgent user interactions, then resume. Suspense boundaries allow React to prioritize hydrating the components a user is interacting with over the rest of the page. This means a user clicking a button will trigger that component to hydrate immediately, even if the surrounding layout has not hydrated yet.

09 / Streaming SSR

Streaming SSR with Suspense

Traditional SSR has a “waterfall” problem: the server must finish fetching all data and rendering all components before it sends the first byte of HTML to the browser. Streaming SSR resolves this by sending HTML in chunks as each Suspense boundary resolves, allowing the browser to render above-the-fold content immediately while the server continues preparing below-the-fold sections.

How Streaming Works

  1. The server begins rendering. Synchronous content (navigation, page shell, static headings) is serialized to HTML immediately.
  2. The first chunk of HTML is flushed to the browser. TTFB is fast because it does not wait for async data.
  3. Async Server Components behind Suspense boundaries resolve in parallel on the server. Each resolved component produces another HTML chunk.
  4. Each chunk arrives at the browser and is inserted inline using a small script injected by React's streaming renderer.
  5. The page fills in progressively. LCP for critical content is unaffected by slow secondary data sources.
tsxStreaming with Suspense in Next.js App Router
// app/dashboard/page.tsx

import { Suspense } from "react";
import { RevenueChart } from "./RevenueChart";     // slow: queries analytics DB
import { RecentOrders } from "./RecentOrders";     // slow: queries orders DB
import { QuickStats } from "./QuickStats";         // fast: queries a cache layer
import { DashboardSkeleton } from "./Skeletons";

export default function DashboardPage() {
  return (
    <div className="dashboard">
      {/* Renders immediately -- no data dependency */}
      <header>
        <h1>Dashboard</h1>
        <nav>...</nav>
      </header>

      {/* Fast: resolves in ~50ms, streams immediately after shell */}
      <Suspense fallback={<div>Loading stats...</div>}>
        <QuickStats />
      </Suspense>

      {/* Slow: resolves in ~800ms -- user sees skeleton, then chart streams in */}
      <Suspense fallback={<DashboardSkeleton type="chart" />}>
        <RevenueChart />
      </Suspense>

      {/* Slow: resolves in ~600ms -- independent of RevenueChart */}
      <Suspense fallback={<DashboardSkeleton type="table" />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

// app/dashboard/loading.tsx -- Next.js special file: Suspense boundary for the route
export default function Loading() {
  return <DashboardSkeleton type="full" />;
}
Streaming vs. parallel data fetching: Suspense boundaries and streaming are distinct from Promise.all-style parallel fetching within a single component. Both techniques are complementary. Use Promise.allinside a single async Server Component to fetch data in parallel before streaming that component's output. Use Suspense boundaries to stream different parts of the page independently of each other.
10 / Partial Prerendering

Partial Prerendering (PPR)

Partial Prerendering, introduced experimentally in Next.js 14 and stabilising in Next.js 15, combines SSG and Streaming SSR into a single page model. The static portions of a page (navigation, layout, any non-personalized content) are pre-rendered at build time and served from a CDN with zero server latency. The dynamic portions (personalized content, live data, user-specific UI) are defined by Suspense boundaries and stream in from the server after the static shell arrives.

The key insight: you declare what is dynamic with Suspense rather than making a page-level choice between static and dynamic. The framework handles splitting and delivering each piece at the appropriate time.

tsxPPR: static shell with dynamic holes
// next.config.mjs
export default {
  experimental: {
    ppr: true,  // enable Partial Prerendering
  },
};

// app/product/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./ProductDetails";   // static -- from build-time data
import { UserRecommendations } from "./Recommendations"; // dynamic -- personalized
import { StockBadge } from "./StockBadge";          // dynamic -- live inventory

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

  return (
    <div>
      {/*
        ProductDetails is a Server Component with no request-time dependencies.
        PPR pre-renders it at build time and serves it from CDN.
        TTFB: ~20ms (CDN hit, no server involved)
      */}
      <ProductDetails productId={id} />

      {/*
        Suspense boundary = dynamic hole in the PPR model.
        The static shell arrives instantly; then this streams from the server.
        TTFB for this section: ~200ms (server fetch + render)
      */}
      <Suspense fallback={<p>Checking stock...</p>}>
        <StockBadge productId={id} />
      </Suspense>

      <Suspense fallback={<p>Loading recommendations...</p>}>
        <UserRecommendations productId={id} />
      </Suspense>
    </div>
  );
}
PPR availability:PPR requires Next.js 14.1+ (experimental) or Next.js 15 (stabilising). It also requires a hosting environment that supports the split static/dynamic delivery model, such as Vercel. Self-hosted deployments need configuration to support PPR's two-phase delivery correctly.
11 / Edge Rendering

Edge Rendering

Edge rendering runs SSR-like logic on edge infrastructure: servers distributed globally at CDN points of presence. Instead of a request travelling to a central origin server, compute runs in the datacenter closest to the user. This dramatically reduces geographic latency and eliminates the overhead of a single origin server under heavy load.

Edge Runtime Constraints

Edge functions do not run the Node.js runtime. They run on the V8 engine with a restricted API surface (similar to a Service Worker). This means:

  • No Node.js built-ins: no fs, no net, no path. Only Web APIs: fetch,Request/Response, ReadableStream,crypto, TextEncoder.
  • Restricted npm packages: many npm packages depend on Node.js internals and will not run at the edge. Check compatibility before reaching for a library in an edge function.
  • Limited CPU per request: edge functions are designed for low-latency, lightweight operations. Long-running computations will hit CPU limits (Cloudflare Workers: 50ms CPU time per request on free tier).
tsEdge Middleware in Next.js: auth redirect and A/B testing
// middleware.ts -- runs at the edge before every matching request
import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: ["/dashboard/:path*", "/account/:path*"],
};

export function middleware(request: NextRequest) {
  const token = request.cookies.get("session")?.value;

  // Edge-native auth check: no database query, just JWT verification
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // A/B testing: route users to different variants
  const variant = request.cookies.get("ab_variant")?.value ?? "a";
  const response = NextResponse.next();
  response.headers.set("x-ab-variant", variant);

  return response;
}

// app/api/personalized/route.ts -- Edge API route
export const runtime = "edge";

export async function GET(request: Request) {
  const geo = (request as Request & { geo?: { country: string } }).geo;
  const country = geo?.country ?? "AU";

  return Response.json({ country, message: `Hello from ${country}` });
}

When to Use Edge Rendering

Good uses for edge

  • Authentication redirects (low latency matters)
  • A/B testing logic before the page renders
  • Geo-targeting and locale routing
  • JWT verification without a database round trip
  • Simple personalization headers

Avoid edge for

  • Complex database queries (no Node.js drivers at edge)
  • Heavy computation or image processing
  • Packages with Node.js built-in dependencies
  • Long-running jobs or background tasks
12 / Rendering Tradeoffs

Rendering Strategy Comparison

A direct comparison of all major rendering patterns across the dimensions that matter for production applications:

PatternTTFBLCPData FreshnessSEOServer CostHosting
CSRFastPoorAlways freshPoorZeroStatic files
SSGInstant (CDN)ExcellentBuild-time onlyExcellentZeroStatic files
ISRInstant (CDN)ExcellentConfigurableExcellentLowNext.js host
SSRMediumGoodAlways freshExcellentHighNode.js server
RSCVariesExcellentAlways freshExcellentMediumNext.js host
Streaming SSRFast (streams early)ExcellentAlways freshGoodMediumNext.js host
PPRInstant (CDN shell)ExcellentPer boundaryExcellentLowNext.js 15
Edge RenderingVery fast (geo)ExcellentAlways freshExcellentLowEdge platform
Mixing is the production reality: most Next.js applications use several of these patterns simultaneously. A marketing site might use SSG for the homepage, ISR for the blog, SSR for the checkout, and Edge Middleware for auth. The skill is knowing which pattern serves each route rather than choosing one for the entire application.
13 / Decision Framework

Choosing the Right Rendering Pattern

Use the questions below to drive the rendering decision for each route. Start with the most constrained requirement and work outward.

Decision Questions

  1. Is this page public and must it rank in search engines? If yes, CSR is ruled out. Start with SSG, ISR, or SSR.
  2. Does the content change per user or per request? If yes, SSG and ISR are ruled out. Use SSR or RSC with streaming.
  3. How fresh does the data need to be? Seconds: SSR. Minutes: ISR with short revalidate. Hours/days: ISR with longer revalidate. Build-time: SSG.
  4. Does the page have a mix of static structure and dynamic data?Yes: PPR if on Next.js 15, or Streaming SSR with Suspense boundaries.
  5. Is there significant geographic distribution of users? Yes: add Edge Middleware for lightweight logic; consider edge-compatible rendering for latency-sensitive routes.
  6. Is there a server at all? No: CSR or SSG only. If SSG, ensure build-time data is sufficient.

Default recommendations

  • Marketing / docs / blog: SSG or ISR
  • E-commerce product pages: ISR or PPR
  • Authenticated dashboards: CSR or RSC with streaming
  • News / live data: SSR or Streaming SSR
  • Auth redirects / A/B testing: Edge Middleware

Red flags for a pattern choice

  • SSR for a page that changes once a week: use ISR
  • CSR for a public landing page: kills LCP and SEO
  • SSG for a page that reads request cookies: will fail at build time
  • Edge runtime for a page using Prisma or fs: will crash
  • PPR in production without testing on the target host
14 / Anti-Patterns

Rendering Anti-Patterns to Avoid

1. Defaulting to SSR for All Routes

Teams new to Next.js often write Server Components that fetch data on every request when the data changes once a day. This turns a free CDN-served page into a server-billed request. Any route whose data does not depend on request-time context should be static or ISR.

tsxAnti-pattern: SSR for static content
// Bad: forces SSR for content that changes daily at most
async function getDocContent() {
  return fetch("/api/docs/intro", { cache: "no-store" }); // no-store forces SSR
}

// Fix: use revalidate for near-static content
async function getDocContent() {
  return fetch("/api/docs/intro", { next: { revalidate: 86400 } }); // 24h ISR
}

2. Using Client-Side Rendering for Public Pages

CSR is appropriate for authenticated internal tools. Using it for public-facing pages (landing pages, product detail, blog) creates measurable LCP and SEO penalties. This is the single most common rendering mistake in React projects.

3. Waterfall Data Fetching in Server Components

tsxAnti-pattern: sequential awaits in a Server Component
// Bad: sequential fetches -- 600ms total if each takes 200ms
export default async function Page() {
  const user = await getUser();          // 200ms
  const orders = await getOrders();      // 200ms (waits for user above)
  const products = await getProducts();  // 200ms (waits for orders above)
  return <Dashboard user={user} orders={orders} products={products} />;
}

// Fix: parallel fetching with Promise.all
export default async function Page() {
  const [user, orders, products] = await Promise.all([
    getUser(),      // all three start simultaneously
    getOrders(),    // total: ~200ms instead of ~600ms
    getProducts(),
  ]);
  return <Dashboard user={user} orders={orders} products={products} />;
}

4. Pushing “use client” Too High

Placing "use client" at the top of a large layout or parent component converts the entire subtree to Client Components, forfeiting the zero-bundle advantage of RSC. The correct pattern is to keep the tree as Server Components and push "use client" down to only the interactive leaf nodes.

tsxAnti-pattern: use client too high in the tree
// Bad: entire layout becomes a Client Component
"use client";
import { useState } from "react";
import { Header } from "./Header";      // now a client component (unnecessary)
import { Sidebar } from "./Sidebar";    // now a client component (unnecessary)
import { MainContent } from "./MainContent"; // now a client component (unnecessary)

export function Layout({ children }: { children: React.ReactNode }) {
  const [menuOpen, setMenuOpen] = useState(false);
  return (
    <div>
      <Header onMenuToggle={() => setMenuOpen((v) => !v)} />
      <Sidebar open={menuOpen} />
      <MainContent>{children}</MainContent>
    </div>
  );
}

// Fix: keep Layout as a Server Component, extract the stateful part
// components/MobileMenu.tsx
"use client";
import { useState } from "react";
export function MobileMenu() {
  const [open, setOpen] = useState(false);
  return <button onClick={() => setOpen((v) => !v)}>Menu</button>;
}

// components/Layout.tsx -- Server Component, no "use client"
import { MobileMenu } from "./MobileMenu";
export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <Header><MobileMenu /></Header>  {/* Only MobileMenu is a Client Component */}
      <Sidebar />                       {/* Still a Server Component */}
      <MainContent>{children}</MainContent>
    </div>
  );
}

5. Reading request-time Data in SSG Pages

Calling cookies(), headers(), orsearchParams in a component that is expected to be statically rendered causes Next.js to fall back to dynamic (SSR) rendering for that route. This surprises many teams: a page they expected to be static is silently rendered dynamically on every request.

Debug rendering mode: run yarn build (ornpm run build) and check the route table output. Next.js marks each route as Static, ƒ Dynamic, or ISR with the revalidate interval. If a route you expected to be static shows as Dynamic, a request-time read is causing the fallback.
15 / Without Next.js

Rendering Patterns Without Next.js

Next.js provides the most complete and integrated rendering model for React in 2026. But not every project uses Next.js. Understanding which patterns are available, which require a different framework, and which require rolling your own solution is essential for making good architectural decisions across different project contexts.

PatternAvailable in Vite SPAAlternative without Next.js
CSRYes (default)Any React setup: Vite, Create React App, Parcel
SSGPartial (static output)Astro (native), Gatsby, Vite with vite-plugin-static-copy + prerender
SSRNo (CSR only)Remix / React Router v7, TanStack Start, Vite SSR mode
ISRNoNo stable framework alternative; approximate with client-side TanStack Query caching
RSCNoRemix (partial), TanStack Start (experimental), Waku
Streaming SSRNoRemix supports Suspense streaming; TanStack Start (experimental)
PPRNoNext.js 15 exclusive feature (as of 2026)
Edge MiddlewareManual onlyDeploy a Cloudflare Worker or Vercel Edge Function manually in front of the SPA

The Vite SPA Rendering Reality

A Vite + React + TypeScript SPA gives you CSR by default. The entire application renders in the browser. There is no server render step, no static build of individual pages, and no edge infrastructure unless you add it manually. This is a real constraint, not just a missing feature. Applications with public pages that need good search ranking or LCP scores should not be plain Vite SPAs.

bashYour options when you need SSR but not Next.js
# Option 1: TanStack Start (experimental, 2026)
yarn create tsup-app my-app --template react-full-stack
# npm create tsup-app@latest my-app -- --template react-full-stack

# Option 2: Remix / React Router v7 (mature, production-ready)
yarn create remix@latest my-app
# npm create remix@latest my-app

# Option 3: Vite SSR mode (low-level, more manual work)
yarn create vite my-app --template react-ts
# npm create vite@latest my-app -- --template react-ts
# Then add vite-plugin-ssr or use Vite's built-in SSR server API

Approximating Dynamic Freshness Without ISR

ISR's stale-while-revalidate model can be approximated on the client using TanStack Query's staleTime and background refetch on window focus. It is not equivalent (data still comes from the browser, not pre-served from CDN), but it provides the same user experience pattern:

tsxClient-side ISR approximation with TanStack Query
import { useQuery } from "@tanstack/react-query";

// Approximates ISR with 60-second revalidation:
// Serve cached data immediately, refetch in background after staleTime
function useProducts() {
  return useQuery({
    queryKey: ["products"],
    queryFn: () => fetch("/api/products").then((r) => r.json()),
    staleTime: 60_000,         // treat as fresh for 60 seconds (like revalidate: 60)
    refetchOnWindowFocus: true, // refetch when user returns to the tab
    refetchInterval: 60_000,   // poll every 60 seconds in the background
  });
}

When to Choose What

Stay with Vite SPA when

  • The app is behind authentication: SEO irrelevant
  • The team is comfortable with SPA patterns and wants simplicity
  • The backend is a separate service your team does not control
  • LCP is not a hard requirement (internal tools, dashboards)
  • No budget or operational capacity for a Node.js server

Upgrade to a full-stack framework when

  • SEO is a business requirement for any route in the app
  • LCP targets cannot be met with CSR on the target hardware
  • The product has a public-facing surface alongside an authenticated one
  • Server-only data access (secrets, databases) is needed in the render path
  • The team is ready for the server/client mental model complexity
The choice of rendering framework is not permanent. Many teams start with a Vite SPA, validate their product-market fit, and migrate to Next.js or Remix when public discoverability becomes a business requirement. The code migration is manageable (React components are largely compatible); the bigger work is re-thinking data fetching and routing conventions.