React NotesRohit’s interview study guide
Chapter 16

Rendering Strategies, SSR & Server Components

Where and when React turns components into HTML — CSR, SSR, SSG and ISR — server rendering with react-dom/server, hydration and its mismatches, streaming, SEO, React Server Components with 'use client' and 'use server', and the Next.js 16 App Router.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#CSR, SSR, SSG and ISR

Every React app has to turn components into HTML at some point. The rendering strategies differ in where that happens (browser or server) and when (per request, at build time, or on a schedule):

  • CSR — client-side rendering. The server sends an almost empty HTML page and a JavaScript bundle; the browser downloads the JS, runs React, fetches data and builds the page. Classic Vite or Create React App SPAs.
  • SSR — server-side rendering. On every request the server runs React, produces the full HTML for that URL and sends it. The browser shows it immediately, then downloads the same JS and hydrates it to make it interactive.
  • SSG — static site generation. The same render as SSR, but done once at build time for every known page; the HTML files are served from a CDN like any static file.
  • ISR — incremental static regeneration. SSG with an expiry: pages are generated at build time (or on first request), served from the cache, and re-generated in the background after a set time or when you invalidate them. A Next.js term; other frameworks call it stale-while-revalidate caching of rendered pages.

The mental model is a restaurant. CSR hands you the ingredients and a recipe (the JS bundle) and you cook at your table. SSR cooks each dish to order in the kitchen. SSG cooks everything before opening and keeps it warm. ISR is SSG where the kitchen quietly replaces a dish when it's been out too long. The diner (user) sees food soonest with SSG, then SSR; with CSR they wait for their own cooking, but once the kitchen is at the table every later dish (client-side navigation) is fast.

App.jsx
import { useState } from "react";

export function App({ products }) {
  const [cart, setCart] = useState(0);
  return (
    <main>
      <h1>Shop</h1>
      <ul>
        {products.map((p) => (
          <li key={p.id}>
            {p.name}: ${p.price}
          </li>
        ))}
      </ul>
      <button onClick={() => setCart((c) => c + 1)}>Cart: {cart}</button>
    </main>
  );
}

// CSR: what the server sends for every URL
// <!doctype html><html><head><title>Shop</title></head>
//   <body><div id="root"></div><script type="module" src="/assets/index-a1b2c3.js"></script></body></html>

// SSR: renderToString(<App products={products} />) on the server gives
// <main><h1>Shop</h1><ul><li>Keyboard<!-- -->: $<!-- -->80</li><li>Mouse<!-- -->: $<!-- -->25</li></ul>
//   <button>Cart: <!-- -->0</button></main>
What’s happening
  1. With CSR the HTML body contains no text at all — the test strips the tags from the CSR shell and gets an empty string. A user (or a crawler that doesn't run JavaScript) sees a blank page until index-a1b2c3.js downloads, React runs, and the products are fetched and rendered.
  2. With SSR the server calls renderToString(<App products={products} />) (run in a Node test) and gets the full markup: the heading, both products and the button. That HTML is the first thing the browser receives, so text is visible before any JavaScript runs.
  3. The <!-- --> comments are React marking where one text node ends and the next begins: Keyboard, : $ and 80 are three separate text children in the JSX. On the client, hydration uses these markers to line up its text nodes with the server's.
  4. The server-rendered button is visible but not yet interactive: onClick handlers don't exist in HTML. Clicking it before hydration does nothing (a test in Hydration proves it). The page becomes interactive once the client bundle loads and React hydrates.
  5. SSG produces exactly the same HTML as SSR — it just runs at build time and stores the result. The difference between the strategies is when and how often this render happens, not what it produces.

From my notes, when to use each, with my corrections inline:

  • Use CSR when you're building a highly interactive SPA where the experience is mostly client-side after the first load; when SEO isn't a concern, such as dashboards and apps used by logged-in users; and when initial load time matters less than interactivity afterwards. All still true.
  • Use SSR when SEO is crucial — blogs, e-commerce, any content-heavy site you want indexed. True, but SSG is just as good for SEO and cheaper when the content doesn't depend on the request (see SEO for React apps).
  • Use SSR for a faster initial load on slow networks, because users don't wait for JavaScript to see content. Mostly true, with a caveat my notes don't mention: content is visible sooner, but the page isn't interactive until the same JavaScript has downloaded and hydrated, and every request now waits for the server to render (a slower time to first byte). A page that looks ready but ignores clicks is its own kind of bad experience.
  • "Static content: SSR (with caching) can be a good fit." Correcting my notes: content that rarely changes is the case for SSG or ISR, not per-request SSR. Rendering the same HTML on every request just to cache it is what SSG does once at build time.
CSRSSRSSGISR
HTML renderedIn the browserOn each requestAt build timeAt build time, re-generated in the background
First content visibleLate (after JS + data)EarlyEarliest (CDN)Earliest (CDN)
InteractiveAfter JSAfter JS + hydrationAfter JS + hydrationAfter JS + hydration
Data freshnessLiveLive per requestAs of the last buildAs of the last regeneration
Server costStatic hostingA server renders every requestStatic hostingStatic hosting + a server to regenerate
Typical useDashboards, internal toolsPersonalised or fast-changing pagesDocs, blogs, marketingProduct pages, news, large catalogues

Modern frameworks don't make you pick one for the whole app. Next.js, React Router's framework mode and TanStack Start choose per route (static for the marketing pages, server-rendered for the account page) and even per component (a static shell with dynamic parts streamed in — see Streaming SSR and Next.js App Router in brief).

#Server rendering with react-dom/server

From my notes: SSR can be done with a framework like Next.js, or manually with react-dom/server's renderToString on a Node server, sending the resulting HTML to the client. My notes have a "full fledged" Express example and add that "you also need webpack and babel".

The mental model: renderToString is React with a string as the output device instead of the DOM. It calls your components once, with no effects, no state updates and no event handlers (none of that can happen on a server), and concatenates the result. The client then has to run the same components with the same data so React can adopt that HTML (hydration).

Here is my notes' server, updated (ES modules instead of require, Express 5 routing syntax, the data embedded for the client, and a hydrating client entry). It runs with Express 5.2 and supertest in the sandbox:

server.jsx
import express from "express";
import { renderToString } from "react-dom/server";
import { App } from "./App.jsx";

const getProducts = async () => [
  { id: 1, name: "Keyboard", price: 80 },
  { id: 2, name: "Mouse", price: 25 },
];

// Data goes into a <script>; escape "<" so a product named "</script>" can't break out.
export const serialize = (data) => JSON.stringify(data).replace(/</g, "\\u003c");

export function createServer() {
  const app = express();

  // Built client assets (index-[hash].js, CSS) from the client build
  app.use("/assets", express.static("dist/client/assets"));

  // Express 5 wildcard syntax: "/{*splat}" (Express 4 accepted "*")
  app.get("/{*splat}", async (req, res) => {
    const products = await getProducts(); // 1. fetch data on the server
    const appHtml = renderToString(<App products={products} />); // 2. render to HTML

    res.send(`<!doctype html>
<html>
  <head><title>My SSR App</title></head>
  <body>
    <div id="root">${appHtml}</div>
    <script>window.__INITIAL_DATA__ = ${serialize(products)}</script>
    <script type="module" src="/assets/client.js"></script>
  </body>
</html>`);
  });

  return app;
}

// main: createServer().listen(3000, () => console.log("Server is running on port 3000"));
client.jsx
import { hydrateRoot } from "react-dom/client";
import { App } from "./App.jsx";

// Same component, same data as the server used -> React can adopt the existing DOM.
hydrateRoot(document.getElementById("root"), <App products={window.__INITIAL_DATA__} />);
What’s happening
  1. A request for /products?sort=price reaches the catch-all route. In Express 5 that's written "/{*splat}"; my notes' app.get('*', …) now throws at startup: a test gets "TypeError: Missing parameter name at index 1: *; visit https://git.new/pathToRegexpError for info". (Express 4 still accepts "*".)
  2. The handler fetches the data on the server first, then calls renderToString. The supertest response is 200 text/html; charset=utf-8 with <div id="root"><main><h1>Shop</h1><ul><li>Keyboard<!-- -->: $<!-- -->80</li>…</div> — the real content, in the HTML.
  3. The same data is embedded as window.__INITIAL_DATA__. This is the step my notes' version was missing: without it the client would render with no products, its output wouldn't match the server's, and React would throw the server HTML away (see Hydration).
  4. serialize replaces every < with \u003c, the JSON escape for the same character. Plain JSON.stringify leaves "</script><script>alert(1)</script>" intact, which would close the script tag and run the attacker's script. The escaped string is still valid JSON and the test shows it round-trips to the original text.
  5. The client entry calls hydrateRoot, not createRoot. createRoot(...).render() would throw away the server's DOM and re-create it (a flash, lost scroll and focus); hydrateRoot walks the existing DOM, attaches event handlers and takes over.
  6. JSX in server.jsx and client.jsx must be compiled, and the client code must be bundled — that's the "you also need webpack and babel" from my notes. Updating my notes: today that's usually Vite, which has an SSR mode (vite build --ssr) that builds the server entry while the normal build makes the client bundle; esbuild or SWC replace Babel. And for anything beyond a demo, a framework (Next.js, React Router framework mode, TanStack Start) does all of this, plus routing, data loading and streaming.

The server APIs in React 19.3, from the installed react-dom package:

APIImportUse
renderToStringreact-dom/serverWhole HTML as a string, synchronously. No streaming, no waiting for data inside Suspense.
renderToPipeableStreamreact-dom/server (Node)Streaming SSR to a Node stream; the recommended Node API.
renderToReadableStreamreact-dom/server (Web streams: edge runtimes, Deno, Bun, and Node)Same, returning a Web ReadableStream.
prerender, prerenderToNodeStreamreact-dom/staticStatic generation: waits for all data, then produces the HTML (SSG).
resume, resumeToPipeableStream, resumeAndPrerenderreact-dom/server, react-dom/staticPartial prerendering (React 19.2+): finish a prerendered shell at request time.
renderToStaticMarkupreact-dom/serverNon-interactive HTML (emails, static snippets); output can't be hydrated.

#Hydration

Added

Hydration is how server-rendered HTML becomes a working React app. The browser has already painted the server's HTML; hydrateRoot then renders the same components in the browser, but instead of creating DOM nodes it walks the existing ones, checks that they match what it would have created, and attaches event handlers and state to them. After hydration the page behaves exactly like a client-rendered app.

The mental model: the server ships a photograph of the UI. Hydration is React walking through the photographed room and wiring every light switch to real electricity, matching each switch in the photo to one it would have installed. If the photo shows a switch where React would have put a socket, it can't wire it — that's a hydration mismatch.

hydration-demo.jsx
import { act } from "react";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { App } from "./App";

const products = [{ id: 1, name: "Keyboard", price: 80 }];

// 1. What the server sent (rendered here with the server renderer)
const container = document.createElement("div");
container.innerHTML = renderToString(<App products={products} />);
document.body.append(container);

const buttonBefore = container.querySelector("button");
buttonBefore.click(); // before hydration: plain HTML, no handler

// 2. The client entry
await act(async () => {
  hydrateRoot(container, <App products={products} />);
});

const buttonAfter = container.querySelector("button");
buttonAfter.click(); // after hydration: React's handler runs
What’s happening
  1. The container holds the server's HTML. Clicking the button now does nothing: the test's click leaves it at "Cart: 0", because HTML has no onClick. This is the gap between "visible" and "interactive" on an SSR page.
  2. hydrateRoot(container, <App products={products} />) renders App in the browser with the same props the server used. React walks the existing DOM in step with its render: <main> matches <main>, <h1> matches, the text nodes line up using the <!-- --> markers, and so on.
  3. Instead of creating nodes, React adopts them. The test checks buttonAfter === buttonBefore and gets true: the same DOM node, now owned by React, with its click handler attached.
  4. The next click runs the handler, setCart updates state, and the button shows "Cart: 1". From here on it's a normal React app.
  5. Effects run after hydration just as after a normal mount, so anything that must only happen in the browser (reading window, localStorage, measuring) belongs in an effect.

Hydration only works if the first client render produces exactly the server's output. When it doesn't, React 19 reports a recoverable error and re-renders that part of the tree on the client, discarding the server HTML:

Hydration.jsx
import { useEffect, useState, useSyncExternalStore } from "react";

// ❌ Different output on server and client
export function BadGreeting() {
  const where = typeof window === "undefined" ? "server" : "browser";
  return <p>Rendered on the {where}</p>;
}

// ✅ Two-pass: render the server's version first, switch after hydration
export function GoodGreeting() {
  const [hydrated, setHydrated] = useState(false);
  useEffect(() => setHydrated(true), []); // effects never run on the server
  return <p>Rendered on the {hydrated ? "browser" : "server"}</p>;
}

// ✅ Or useSyncExternalStore: getServerSnapshot is used on the server AND during hydration
const subscribe = () => () => {};
export function useIsClient() {
  return useSyncExternalStore(subscribe, () => true, () => false);
}
export function Width() {
  const isClient = useIsClient();
  return <p>Width: {isClient ? window.innerWidth : "unknown"}</p>;
}

// ✅ For a single unavoidable difference (timestamps)
export function Stamp({ date }) {
  return <time suppressHydrationWarning>{date.toLocaleTimeString()}</time>;
}
What’s happening
  1. The server renders BadGreeting with no window, so it sends <p>Rendered on the <!-- -->server</p>. (The test simulates the server by removing window while calling renderToString.)
  2. On the client, the first render says "browser". The text doesn't match, and hydrateRoot's onRecoverableError receives: "Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used: - A server/client branch if (typeof window !== 'undefined'). - Variable input such as Date.now() or Math.random() which changes each time it's called. - Date formatting in a user's locale which doesn't match the server. …". The final DOM is <p>Rendered on the browser</p>, built from scratch on the client — the server's work was wasted.
  3. GoodGreeting renders "server" on both sides during hydration (state starts false everywhere), so hydration matches: zero recoverable errors. Then the effect runs in the browser, sets hydrated to true, and a normal update changes the text to "browser".
  4. Width gets the same result with useSyncExternalStore: its third argument, getServerSnapshot, is used on the server and during hydration, so both render "Width: unknown"; React then re-renders with the client snapshot. The test sees no errors and a final DOM of Width: 1024 (jsdom's window width).
  5. Stamp's server and client times differ by five seconds. suppressHydrationWarning silences the mismatch for that one element's text — and note what the test shows: the DOM keeps the server's text. React doesn't patch it, so only use it for content where a stale value is acceptable, and only one level deep.

Common causes of mismatches: typeof window checks in render, Date.now()/Math.random()/crypto.randomUUID() in render (use useId for ids), locale-dependent formatting (server and user in different time zones), reading localStorage during render, invalid HTML nesting (a <div> inside a <p>, which the browser's parser "fixes" so the DOM no longer matches), and browser extensions that inject elements. React 19 also tolerates unexpected tags that third-party scripts and extensions insert in <head> and <body>, which used to cause spurious mismatches.

Why does this mismatch?
React
function Price() {
  return (
    <p>
      Price: <div className="amount">$80</div>
    </p>
  );
}

The server renders it, the client hydrates it with exactly the same props, and there's no Date, window or randomness anywhere. Why does hydration still fail?

Show answer

Because the browser's HTML parser changes the server's HTML before React ever sees it: a <div> isn't allowed inside a <p>.

What’s happening
  1. renderToString just concatenates strings, so the server sends <p>Price: <div class="amount">$80</div></p> (real output).
  2. The HTML parser reads <p>Price: , then meets <div>. A <p> can't contain block elements, so the parser closes the paragraph before the div. The closing </p> later has no open paragraph, so the parser creates an empty one. The DOM it builds is <p>Price: </p><div class="amount">$80</div><p></p> — the test prints exactly that.
  3. In development React already warned while rendering: "In HTML, <div> cannot be a descendant of <p>. This will cause a hydration error."
  4. During hydration React expects a <div> inside the <p>, finds the paragraph ending after the text, and reports "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client." It re-renders on the client, and the final DOM is the nested version React created with DOM APIs (which, unlike the parser, allow it).
  5. The fix is valid HTML: make the outer element a <div>, or the inner one a <span>. Other parser-rewritten cases: <a> inside <a>, <tr> directly inside <table> without <tbody>, <form> inside <form>.
AdvancedSelective hydration

With streaming SSR, React hydrates each Suspense boundary independently. Parts of the page become interactive as soon as their code and HTML have arrived, and if the user clicks a part that isn't hydrated yet, React prioritises hydrating that boundary first. You don't call anything to get this; it comes from using <Suspense> with hydrateRoot and the streaming server APIs.

#Streaming SSR

Added

renderToString has two problems on a real page: the server can't send anything until the slowest data on the page has been fetched, and the client can't hydrate anything until all the JavaScript has loaded. Streaming SSR fixes both by using <Suspense> boundaries as seams: the server sends the HTML outside the boundaries (the shell) right away, with fallbacks in place of slow parts, then keeps the connection open and streams each part's HTML when its data is ready.

The mental model is a newspaper delivered in sections: the front page arrives first, and the sports results are slipped through the letterbox later, already laid out, along with a note saying which page they replace. The reader starts reading straight away.

Streaming.jsx
import { Suspense, use } from "react";

export function Reviews({ reviewsPromise }) {
  const reviews = use(reviewsPromise); // suspends until the promise resolves
  return (
    <ul>
      {reviews.map((r) => (
        <li key={r}>{r}</li>
      ))}
    </ul>
  );
}

export function ProductPage({ reviewsPromise }) {
  return (
    <html>
      <head>
        <title>Keyboard</title>
      </head>
      <body>
        <h1>Keyboard</h1>
        <p>$80 — in stock</p>
        <Suspense fallback={<p>Loading reviews…</p>}>
          <Reviews reviewsPromise={reviewsPromise} />
        </Suspense>
      </body>
    </html>
  );
}
server-stream.js
import { renderToPipeableStream } from "react-dom/server";

app.get("/{*splat}", (req, res) => {
  const reviewsPromise = fetchReviews(); // starts now, resolves in ~50 ms
  const { pipe } = renderToPipeableStream(<ProductPage reviewsPromise={reviewsPromise} />, {
    bootstrapScripts: ["/assets/client.js"],
    onShellReady() {
      res.setHeader("content-type", "text/html");
      pipe(res); // start streaming as soon as the shell is ready
    },
    onShellError() {
      res.status(500).send("<h1>Something went wrong</h1>");
    },
    onAllReady() {
      // everything rendered; for crawlers, wait for this before piping instead
    },
    onError(error) {
      console.error(error); // errors inside Suspense boundaries
    },
  });
});
What’s happening
  1. renderToPipeableStream starts rendering. Everything outside the <Suspense> boundary renders immediately; Reviews calls use(reviewsPromise), the promise isn't resolved, so that boundary is left pending. The shell is complete, and the test logs [3ms] onShellReady.
  2. pipe(res) sends the first chunk at 4 ms: <!DOCTYPE html><html><head><link rel="preload" as="script" fetchPriority="low" href="/assets/client.js"/><title>Keyboard</title></head><body><h1>Keyboard</h1><p>$80 — in stock</p><!--$?--><template id="B:0"></template><p>Loading reviews…</p><!--/$-->…<script src="/assets/client.js" id="_R_" async=""></script>. The user can already read the product and price, and the client bundle is already downloading (React added the preload for the bootstrapScripts entry).
  3. <!--$?--> marks a pending boundary and <template id="B:0"> is its placeholder. The connection stays open.
  4. At ~53 ms the reviews promise resolves, React renders Reviews, and the test logs onAllReady followed by the second chunk: <div hidden id="S:0"><ul><li>Great keys</li><li>Too loud</li></ul></div> plus a small inline script ending in $RC("B:0","S:0"). That script moves the hidden content into the placeholder's spot and removes the fallback — without React having loaded. The last chunk is </body></html>.
  5. When client.js arrives, hydrateRoot(document, <ProductPage …/>) hydrates the shell and each boundary as it's ready (selective hydration). The client needs the reviews data too; frameworks serialise it into the stream for you.
  6. Errors in the shell call onShellError (send a 500 page); errors inside a boundary call onError and the boundary falls back to client rendering. For bots that don't execute JavaScript, call pipe in onAllReady instead of onShellReady, so they get the complete HTML in one go.

For static generation, prerender from react-dom/static is the "wait for everything" version: a test passes a reviews promise that resolves after 20 ms and gets one finished document, <!DOCTYPE html><html>…<h1>Keyboard</h1><p>$80 — in stock</p><!--$--><ul><li>Great keys</li></ul><!--/$--></body></html>, with no fallbacks and no inline scripts — ready to write to a file at build time. (Its prelude is a Web ReadableStream; prerenderToNodeStream returns a Node stream instead.)

AdvancedPartial prerendering: prerender a shell, resume per request

React 19.2 added the pieces for partial prerendering (PPR): prerender a page at build time, stop at the parts that need request data, save where you stopped, and finish those parts per request. Next.js's Cache Components are built on this. A test with React 19.3:

ppr.jsx
import { resumeToPipeableStream } from "react-dom/server";
import { prerender } from "react-dom/static";

// Build time: abort before the reviews resolve, so they become a "hole".
const controller = new AbortController();
const p = prerender(<ProductPage reviewsPromise={new Promise(() => {})} />, { signal: controller.signal });
setTimeout(() => controller.abort(), 10);
const { prelude, postponed } = await p;
// prelude: the static shell (store it on a CDN)
// postponed: JSON-serialisable state describing the holes (store it next to the shell)

// Request time: send the stored shell, then resume with real data.
const { pipe } = resumeToPipeableStream(<ProductPage reviewsPromise={fetchReviews()} />, postponed, {
  onShellReady: () => pipe(res),
});
What’s happening
  1. prerender renders as far as it can. The reviews never resolve, so when the signal aborts, the boundary is left as a hole and the call resolves with prelude and postponed. (React also reports the abort reason, an AbortError, through onError, which logs it by default.)
  2. The shell is a complete static page: …<h1>Keyboard</h1><p>$80 — in stock</p><!--$?--><template id="B:0"></template><p>Loading reviews…</p><!--/$-->…. It can be served instantly from a CDN.
  3. postponed is plain data (632 characters of JSON in the test) recording which boundaries were left unfinished. It's what lets a different process, later, continue the same render.
  4. At request time, resumeToPipeableStream(element, postponed) renders only the holes. Its output in the test is just <div hidden id="S:0"><ul><li>Great keys</li></ul></div> plus the $RC("B:0","S:0") swap script — the shell isn't rendered again.
  5. The result: a static shell with the speed of SSG, and request-specific parts streamed in like SSR. You rarely call these APIs yourself; frameworks stitch the stored shell and the resumed stream together.

#SEO for React apps

From my notes: you improve the SEO of a React application by implementing SSR, or using a framework like Next.js, which generates HTML on the server so search engines can index the page easily. That's the core answer, and it's right. The rest of this topic is the detail behind it.

The mental model: a search engine (or a link-preview bot for Slack, LinkedIn or WhatsApp) is a visitor in a hurry with JavaScript often switched off. Googlebot does run JavaScript, but rendering is queued separately from crawling, so JS-only content can be indexed later and less reliably; many other crawlers and almost every social preview bot don't run JavaScript at all. Whatever matters for discovery must be in the HTML the server sends: the content, the title and description, the links, and the structured data.

Seo.jsx
export function ProductSeo({ product }) {
  const url = `https://shop.example.com/products/${product.slug}`;
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    offers: { "@type": "Offer", price: product.price, priceCurrency: "CAD" },
  };
  return (
    <article>
      {/* React 19: these are hoisted into <head>, wherever they're rendered */}
      <title>{`${product.name} | Shop`}</title>
      <meta name="description" content={product.summary} />
      <link rel="canonical" href={url} />
      <meta property="og:title" content={product.name} />
      <meta property="og:image" content={product.image} />

      <h1>{product.name}</h1>
      <p>{product.summary}</p>
      {/* Structured data for rich results; escape < like any inline JSON */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
      />
    </article>
  );
}

export function Document({ children }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
      </head>
      <body>{children}</body>
    </html>
  );
}

// renderToString(<Document><ProductSeo product={product} /></Document>)
What’s happening
  1. ProductSeo renders <title>, <meta> and <link> in the middle of the page component, next to the data they describe. Before React 19 that was invalid (they must be in <head>) and you needed react-helmet or a framework API.
  2. React 19 hoists them. Rendering the whole document on the server puts <title>Mechanical Keyboard | Shop</title>, the description, the canonical link and both Open Graph tags inside <head>, after the existing <meta charSet>, while the <article> stays in <body> (real test output). See Document metadata, stylesheets and preloading.
  3. On the client it works too: a jsdom test renders ProductSeo with render() and document.title becomes "Keyboard | Shop", with the tags in document.head. Client-only hoisting helps browser tabs and Google, but not bots that don't run JS — those need the server render.
  4. Hoisting needs a full <html> document on the server; renderToString(<ProductSeo />) on its own just emits the tags inline where they were rendered.
  5. The JSON-LD <script> stays in place (structured data can be anywhere) and goes out as {"@context":"https://schema.org","@type":"Product",…}. It's how product prices, ratings and breadcrumbs show as rich results. The < escaping matters here for the same reason as in the SSR example: product names are user data.

The checklist interviewers expect, beyond "use SSR":

  • Render content and metadata on the server (SSR or SSG). Per-page <title>, <meta name="description">, Open Graph/Twitter tags for link previews, <link rel="canonical"> to avoid duplicate URLs (?sort= variants). Frameworks have APIs for this: Next.js metadata/generateMetadata, React Router meta exports, or React 19's built-in tags.
  • Real links. Navigation must be <a href> elements (React Router's <Link> renders one); crawlers follow hrefs, not onClick handlers.
  • Correct status codes. A missing product must return 404, not a 200 page that says "not found" (a "soft 404"). That's only possible when the server renders the route — a CSR app always returns 200 with index.html.
  • Semantic HTML. One <h1>, headings in order, <nav>, <main>, <article>, alt text on images.
  • Sitemap and robots.txt, so crawlers find every page and skip the ones that don't matter.
  • Performance. Core Web Vitals (LCP, INP, CLS) are a ranking signal: SSR/SSG for fast LCP, reserved image sizes to avoid layout shift, less JavaScript for a good INP.
  • Stable URLs per piece of content, not state hidden in memory — the router's job (see Route params and useLocation).

#React Server Components

Added

My notes mention "server rendered component although no tooling yet… so better to use Next.js". That was the situation in late 2024. Server Components are now stable in React 19, and the tooling exists, but the advice still holds in spirit: you use them through a framework or bundler integration (Next.js App Router is the most complete; React Router has RSC support marked unstable; there are Vite and Parcel integrations), not by calling an API yourself. Nothing in this topic could run in the sandbox, which has no RSC bundler; the code follows the Next.js 16 and react.dev docs and wasn't executed.

A Server Component is a component that runs only on the server (at request time or at build time) and is never sent to the browser as JavaScript. It can be async, it can read a database or the file system directly, and it can import large libraries (a Markdown parser, a syntax highlighter) at zero cost to the bundle. What reaches the browser is its output — a serialised description of the rendered tree, called the RSC payload — not its code. Client Components are the components you already know: they're also server-rendered to HTML, then shipped as JS and hydrated, and they're the only ones that can use state, effects and event handlers.

The mental model: a Server Component is like a PHP template that outputs React elements instead of HTML strings. It runs where the data is, does its work, and hands the browser a finished description with holes in it, and the holes are Client Components ("put an interactive <LikeButton count={12} /> here"). This is not the same as SSR: SSR is about producing HTML for a first paint, for any component; RSC is about where a component's code lives and runs at all. They're used together: server components produce the RSC payload, and SSR turns the whole tree (server output plus client components) into HTML for the first load.

app/products/[id]/page.jsx
// A Server Component (the default in the Next.js App Router): no directive needed.
import { db } from "@/lib/db";
import { formatPrice } from "@/lib/format"; // runs on the server only; never in the bundle
import { LikeButton } from "./LikeButton";
import { Reviews } from "./Reviews";

export default async function ProductPage({ params }) {
  const { id } = await params; // Next.js 15+: params is a Promise
  const product = await db.product.findUnique({ where: { id } }); // direct DB access

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{formatPrice(product.price)}</p>
      <LikeButton productId={product.id} initialLikes={product.likes} />
      <Reviews productId={product.id} /> {/* another Server Component */}
    </article>
  );
}
app/products/[id]/LikeButton.jsx
"use client"; // this file and everything it imports run in the browser too

import { useState } from "react";

export function LikeButton({ productId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  return <button onClick={() => setLikes((n) => n + 1)}>♥ {likes}</button>;
}
What’s happening
  1. A request for /products/42 runs ProductPage on the server. It's an async function component — only Server Components can be async — so it can await params and then query the database directly. There's no API route, no useEffect, no loading state for this data, and no database credentials anywhere near the browser.
  2. formatPrice and the db client are imported by a Server Component only, so they're not in the client bundle. A Server Component's dependencies cost the user nothing to download.
  3. <LikeButton> comes from a file marked "use client". The server doesn't run its effects or handlers; it puts a reference into the RSC payload — roughly "client component LikeButton from chunk X, with props { productId: "42", initialLikes: 12 }". Those props cross the network, so they must be serialisable (strings, numbers, plain objects, arrays, Dates, promises, JSX, Server Functions) — not ordinary functions or class instances.
  4. <Reviews> is another Server Component; it can fetch its own data, and wrapped in <Suspense> its output can stream in later (see Streaming SSR).
  5. On a first load, the framework also SSRs the whole tree to HTML (LikeButton included, showing "♥ 12"), then the browser downloads only LikeButton's code and hydrates it. On client-side navigation, the browser fetches just the RSC payload for the new page and React merges it into the existing tree, keeping client state where the tree didn't change.
  6. What this demonstrates: the data-fetching, formatting and layout code stayed on the server, and the only JavaScript shipped is the one interactive button.

The rules, as you'd state them in an interview:

  • Server Components can't use state, effects, refs, context or browser APIs, and can't take event handler props — there's no browser and no re-render on the server. Those need Client Components.
  • Client Components can't import Server Components (an import from a "use client" file makes the imported module client code too). But they can receive them as props, typically children: <ClientTabs>{<ServerList />}</ClientTabs> works because the server renders ServerList and passes its output in.
  • Props from server to client must be serialisable. Passing a function fails with an error telling you only Server Functions ('use server') can be passed.
  • Server Components are the default in the App Router; you opt into the client per module with 'use client', and you push that boundary as far down the tree as possible, to the interactive leaves.
  • Server-only code should say so: import "server-only" at the top of a module (a small package Next.js supports) makes the build fail if a Client Component ever imports it, so secrets can't leak into the bundle by accident.

#'use client' and 'use server'

Added

React's two directives are string literals at the top of a module or function, and both mark a boundary between the server and client module graphs — not "where this code runs" in the simple sense. 'use client' says: from this module down, code can also run in the browser — ship it, and let the server reference it. 'use server' says: these async functions stay on the server — give the client a way to call them over the network.

The mental model is two doors in the wall between server and browser. 'use client' is the door the server uses to hand the browser an interactive component ("render this, here are its props"). 'use server' is the door the browser uses to ask the server to do something ("run addTodo with this form data"). Everything else stays on its own side.

app/todos/actions.js
"use server"; // every export is a Server Function the client can call

import { updateTag } from "next/cache";
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";

export async function addTodo(prevState, formData) {
  const session = await getSession(); // a Server Function is a public endpoint: check who's calling
  if (!session) return { error: "Please log in" };

  const title = String(formData.get("title") ?? "").trim(); // and never trust the input
  if (!title) return { error: "Title is required" };

  await db.todo.create({ data: { title, userId: session.userId } });
  updateTag("todos"); // Next.js 16: expire cached todo data so the next render reads fresh data
  return { error: null };
}
app/todos/AddTodoForm.jsx
"use client";

import { useActionState } from "react";
import { addTodo } from "./actions";

export function AddTodoForm() {
  const [state, formAction, isPending] = useActionState(addTodo, { error: null });
  return (
    <form action={formAction}>
      <input name="title" aria-label="Title" />
      <button disabled={isPending}>{isPending ? "Adding…" : "Add"}</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
app/todos/page.jsx
import { db } from "@/lib/db";
import { AddTodoForm } from "./AddTodoForm";

export default async function TodosPage() {
  const todos = await db.todo.findMany(); // Server Component: reads directly
  return (
    <>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>{t.title}</li>
        ))}
      </ul>
      <AddTodoForm />
    </>
  );
}
What’s happening
  1. page.jsx has no directive, so it's a Server Component: it reads the todos from the database and renders the list. AddTodoForm is imported from a "use client" file, so the server puts a client reference in its output and the form's code ships to the browser.
  2. AddTodoForm imports addTodo from a "use server" file. The bundler does not put addTodo's code (or db, or getSession) in the client bundle. The client gets a stub with an id; calling it sends a POST request to the server with the arguments, and the server runs the real function.
  3. useActionState(addTodo, { error: null }) wraps the Server Function: React passes (prevState, formData), tracks isPending, and stores whatever the function returns as the new state (see useActionState). <form action={formAction}> works before hydration too — without JavaScript the browser submits the form normally and the server runs the action (progressive enhancement).
  4. On submit, addTodo runs on the server. It checks the session and validates the title itself, because a Server Function is a public HTTP endpoint: anyone can call it with any arguments, not just your form. React Router's 8.4 changelog adds the same warning for its RSC support.
  5. After saving, updateTag("todos") expires the cached data tagged todos (Next.js 16's read-your-writes API, Server Actions only), and Next.js re-renders the page's Server Components in the same response, so the new todo appears in the list — no client-side state update code.
  6. If validation fails, the returned { error: "Title is required" } becomes state and the client shows it in the alert. The only code that ran in the browser was the form component.

Things people get wrong:

  • 'use client' doesn't mean "client-only". Client Components are still rendered to HTML on the server during SSR. Code that touches window still has to go in effects or event handlers.
  • 'use client' is needed only at the boundary, the entry file the server imports. Modules imported by a client module are client code automatically; putting the directive in every file is unnecessary.
  • 'use server' isn't for Server Components. Server Components need no directive. 'use server' marks Server Functions — functions callable from the client. React's docs call them Server Functions in general and Server Actions when they're used as a form action or inside a transition.
  • Server Functions are for mutations, not data fetching. React's docs note that frameworks typically process them one at a time and can't cache their results; read data in Server Components (or loaders), write with Server Functions.
  • Arguments and return values of Server Functions must be serialisable (FormData, plain objects, primitives…), for the same reason props to client components must be.

#Next.js App Router in brief

Added

Next.js is the most widely used React framework, and since version 13 it has two routers. The App Router (app/ directory) is the modern one, built on React Server Components, streaming and Suspense; the Pages Router (pages/ directory) is the original, still supported, and still in many codebases. This topic covers the App Router in Next.js 16 (current release 16.4), with the Pages Router equivalents for comparison. Next.js isn't installed in the sandbox, so the examples follow the official docs and weren't executed.

The mental model: folders are routes, special files are their parts. app/blog/[slug]/page.jsx is the page at /blog/:slug; the layout.jsx, loading.jsx and error.jsx files next to it wrap it in a shared layout, a Suspense boundary and an error boundary. Everything is a Server Component unless a file says 'use client'.

app/blog/[slug]/page.jsx
import { notFound } from "next/navigation";
import { getPost, getAllSlugs } from "@/lib/posts";

// SSG for known posts: Next.js prerenders these at build time
export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

// Per-page SEO metadata, rendered into <head> on the server
export async function generateMetadata({ params }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return { title: post?.title, description: post?.summary };
}

export default async function BlogPost({ params }) {
  const { slug } = await params; // params is a Promise in Next.js 15+, and must be awaited in 16
  const post = await getPost(slug);
  if (!post) notFound(); // renders not-found.jsx with a 404 status

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}
What’s happening
  1. The file path defines the route: app/blog/[slug]/page.jsx serves /blog/:slug, and [slug] is a dynamic segment. A folder without a page file isn't a public route, so components, tests and helpers can live next to the route.
  2. generateStaticParams returns [{ slug: "hello" }, …], and next build prerenders each of those pages to static HTML — SSG. Slugs not in the list are rendered on first request (and, with caching, stored).
  3. generateMetadata runs on the server and returns the page's <title> and description; Next.js renders them into <head>, which covers the SEO checklist without manual tags.
  4. BlogPost is an async Server Component. params is a Promise since Next.js 15, and Next.js 16 removed the temporary synchronous access, so it must be awaited (or read with use(params) in a Client Component).
  5. notFound() throws a special error that Next.js catches: it renders the nearest not-found.jsx and sends a real 404 status — the "correct status codes" point from SEO, solved by the framework.

The special files, from the outside in for one route segment:

FileRole
layout.jsxShared UI that wraps child segments and stays mounted across their navigations. The root app/layout.jsx is required and renders <html> and <body>.
template.jsxLike a layout, but re-mounted on every navigation.
error.jsxAn error boundary for the segment. Must be a Client Component; receives error and, since 16.3, a stable retry() that re-fetches and re-renders the segment.
loading.jsxA Suspense fallback for the segment, shown instantly while the page streams.
not-found.jsxUI for notFound() and unmatched URLs.
page.jsxThe route's unique UI; makes the segment publicly reachable.
route.jsA Route Handler: export GET, POST… functions that take a Request and return a Response (an API endpoint).

Folder conventions: [slug] (dynamic segment), [...slug] (catch-all), (marketing) (a route group: organises files and layouts without adding a URL segment), @sidebar (parallel route slot; every slot needs a default.js in Next.js 16), _components (private folder, never a route).

What changed in Next.js 16, from the release notes:

  • Cache Components. Caching is now opt-in and explicit: with cacheComponents: true in next.config, everything is dynamic by default, and you cache a page, component or function by adding 'use cache' (with cacheLife('hours') and cacheTag('posts') from next/cache). Next.js prerenders a static shell and streams the uncached, request-specific parts — partial prerendering, built on React's prerender/resume. The old route segment options export const revalidate, dynamic and fetchCache are replaced (and error) when it's on.
  • Invalidation APIs: updateTag(tag) in Server Actions for read-your-writes; revalidateTag(tag, 'max') now requires a cache profile as its second argument for stale-while-revalidate; refresh() re-renders uncached data.
  • proxy.ts replaces middleware.ts (rename the file and export a proxy function); it runs on the Node.js runtime.
  • Turbopack is the default bundler for next dev and next build (--webpack to opt out); React Compiler support is stable (reactCompiler: true, off by default).
  • Async request APIs only: params, searchParams, cookies(), headers() and draftMode() must be awaited.
  • Removed: AMP, the next lint command, serverRuntimeConfig/publicRuntimeConfig. Minimum Node.js 20.9.
AdvancedThe same page with Cache Components (Next.js 16)

With cacheComponents: true, ISR is expressed as caching the data or component rather than configuring the route:

app/blog/[slug]/page.jsx
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";

async function getPost(slug) {
  "use cache"; // the result is cached; slug is part of the cache key
  cacheLife("hours"); // was: export const revalidate = 3600
  cacheTag(`post-${slug}`); // lets a Server Action call updateTag(`post-${slug}`) after an edit
  const res = await fetch(`https://cms.example.com/posts/${slug}`);
  return res.json();
}

export default function Page({ params }) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Post params={params} />
    </Suspense>
  );
}

async function Post({ params }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <h1>{post.title}</h1>;
}
What’s happening
  1. "use cache" at the top of getPost makes its return value cacheable. The cache key is built from the function's identity and its serialisable arguments, so each slug gets its own entry.
  2. cacheLife("hours") replaces the old export const revalidate = 3600 route option (the migration guide maps one to the other); cacheTag names the entry for on-demand invalidation.
  3. params is awaited inside a component wrapped in <Suspense> rather than at the top of the page. That lets Next.js prerender the page's static shell (here just the fallback, in a real page the layout and nav) even though the slug isn't known at build time, and stream the post in per request — partial prerendering.
  4. After an editor saves, a Server Action calls updateTag(`post-${slug}`) and the next request renders fresh data; a CMS webhook in a Route Handler would call revalidateTag(`post-${slug}`, "max") for stale-while-revalidate instead.

App Router vs Pages Router, for codebases that have both:

Pages Router (Next.js 12 era, still supported)App Router (Next.js 13+, current)
Routespages/blog/[slug].jsxapp/blog/[slug]/page.jsx
ComponentsAll Client Components (SSR + hydrate)Server Components by default, 'use client' to opt in
Server datagetServerSideProps (per request), getStaticProps + getStaticPaths (build time, revalidate for ISR)async Server Components, generateStaticParams, 'use cache'
Layouts_app.jsx, per-page getLayout patternsNested layout.jsx files
MutationsAPI routes + client fetchServer Functions ('use server'), Route Handlers
Loading / errorsManualloading.jsx (Suspense), error.jsx (error boundary)
Navigation hooksuseRouter from next/routeruseRouter, usePathname, useSearchParams from next/navigation
Head tagsnext/headmetadata / generateMetadata, or React 19 <title>
Server or client?

In a Next.js App Router project, which of these must be Client Components: (a) a page that reads a product from the database, (b) a search box that filters as you type, (c) a layout that renders the site header with a logged-in user's name read from a cookie, (d) a chart that uses a library calling window.matchMedia?

Show answer

(b) and (d). (a) and (c) can stay Server Components.

What’s happening
  1. (a) Reading from a database is exactly what Server Components are for: an async component that awaits the query. No state, no handlers, so no 'use client'.
  2. (b) Filtering as you type needs useState (or useSearchParams) and an onChange handler, which only Client Components can have. Make the search box the client component and keep the page around it on the server.
  3. (c) The layout can await cookies() on the server and render the name; nothing interactive is required. (If the header had a dropdown menu, only the dropdown would be a client component, with the name passed in as a prop.)
  4. (d) window.matchMedia exists only in the browser, and the chart library almost certainly uses state and effects, so its wrapper needs 'use client'. Its data can still be fetched by a Server Component and passed down as serialisable props.
  5. The pattern: Server Components by default, and push 'use client' down to the smallest interactive leaves.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.