Server Components render only on the server (at build time or per request), can be async and read a database or files directly, and reach the browser as serialized UI with no JavaScript. Client Components ("use client") also render to HTML on the server, then hydrate and run in the browser.
Server Components: no state, no effects, no event handlers, no browser APIs; their code and dependencies never reach the bundle, so a Markdown parser used in one costs the client nothing.
Client Components: useState, useEffect, event handlers and browser APIs; their code ships to the browser.
Default: in the Next.js App Router every component is a Server Component until a "use client" boundary.
Composition: a Server Component can render a Client Component and pass it serializable props, or other Server Components as children; a Client Component cannot import a Server Component.
RSC is a React feature that needs a framework for the bundler and server side: Next.js App Router is the main production one, React Router and Waku add support.
ReactSenior
Server Components & Next.js
React Server Components, server functions, CSR vs SSR vs SSG vs ISR, streaming, hydration errors, the Next.js App Router, caching
Questions closed
0 / 22
- 0Review
- 13Asked often
Questions
They differ in where and when the HTML is produced: CSR builds it in the browser from JavaScript, SSR on the server for each request, SSG once at build time, and ISR at build time with pages regenerated in the background after a set time or on demand.
CSR (a Vite SPA): simple and cheap to host, but a blank page until the JavaScript loads and weak SEO without prerendering. Fits dashboards behind a login.
SSR: fresh, personalized HTML and good SEO; costs server time and time to first byte on every request. Fits pages that depend on cookies or live data.
SSG: the fastest, served from a CDN, but content is as old as the last build. Fits docs, blogs and marketing pages.
ISR: SSG plus time-based or on-demand revalidation, so a product catalogue stays cached yet updates without a full rebuild.
In the Next.js App Router the choice is per route, and with Cache Components (Next.js 16) per component: a static shell is prerendered and dynamic parts stream in.
In the App Router each folder under app/ is a URL segment, and special files in it define that segment's UI: page.tsx makes it routable, layout.tsx wraps it and persists across navigation, and loading.tsx and error.tsx add a Suspense boundary and an error boundary around it.
layout: keeps its state and does not re-render when you navigate between its child pages; the root app/layout.tsx must render <html> and <body>.
template: like a layout, but remounts on every navigation.
loading: wraps the page in <Suspense>, so a skeleton shows at once while the page streams.
error: must be a Client Component and receives error and reset; it does not catch errors thrown by the layout of the same segment (the parent's error.tsx does), and global-error.tsx covers the root layout.
not-found: rendered when the code calls notFound() or no route matches.
Folders: [id] is a dynamic segment, (group) organizes files without changing the URL, _private is excluded from routing, @slot is a parallel route; route.ts defines an HTTP handler instead of a page.
"use client" at the top of a file marks a boundary in the module graph: that module and everything it imports become client code, bundled and hydrated in the browser. It does not mean "render only in the browser": Client Components are still rendered to HTML on the server.
Put it on the leaves that need interactivity (a button, a form, a carousel), not on a page or layout, or the whole subtree and its dependencies move into the bundle.
A Client Component can still show server-rendered content: accept children or other ReactNode props and let a Server Component fill them.
Only the boundary file needs the directive; modules it imports become client code automatically. A file without it is shared and runs on whichever side imports it.
A third-party component that uses hooks but lacks the directive needs a one-line wrapper file with "use client" that re-exports it.
"use server" is not the opposite: it marks server functions callable from the client, not Server Components.
Props that cross the boundary must be serializable by React: primitives, plain objects and arrays, Date, Map, Set, typed arrays, promises, JSX and server functions. Ordinary functions, class instances and symbols not registered with Symbol.for fail with an error.
Functions: "Functions cannot be passed directly to Client Components" unless the function is a server function ("use server"), which is sent as a reference the client can call.
Class instances: a Prisma Decimal, an ORM entity or your own class must be converted to plain data first.
Promises: pass a promise without awaiting it and read it in the client with use(promise) inside Suspense, so the server does not block on that data.
JSX: children and other element props are rendered on the server and sent as UI, not as code.
Size: every prop is embedded in the RSC payload and the HTML, so pass the fields the client needs, not whole records with private columns.
CartPage is a Server Component, and a Server Component cannot attach event handlers: a function cannot be serialized into the RSC payload, and none of this component's code runs in the browser to call it.
Server Components have no interactivity: no onClick, no useState, no effects.
Fix: move the interactive part into a small Client Component and pass it only serializable props, such as cartId.
Adding "use client" to the whole page is the wrong fix: it moves the page and everything getCart imports into the bundle, and an async Client Component is not supported.
For a real mutation such as checkout, the button can call a server function, or a <form action={checkout}> can work even before hydration.
Code imported only by Server Components never ships, but one stray import from a Client Component pulls it into the bundle. Add import 'server-only' to modules that touch secrets or the database, so such an import fails the build instead of leaking.
Environment variables: Next.js inlines only NEXT_PUBLIC_ variables into client code; the rest are undefined there, which hides the mistake instead of reporting it.
**client-only is the mirror: it fails the build if a browser-only module ends up on the server.
Props leak data**: whatever a Server Component passes to a Client Component is serialized into the page; select the fields you need and never pass a user row with passwordHash.
Data access layer: keep database and auth calls in server-only modules that return DTOs, so every path goes through the same checks.
React's taint APIs (experimental_taintObjectReference, experimental_taintUniqueValue) can block a specific object or token from reaching the client; they are still experimental.
A Server Function is an async function marked with "use server" that runs on the server but can be called from the client: React sends the arguments in a POST request and returns the result. Passed to a form's action, it is called a Server Action and works even before the JavaScript loads.
Declaring: put "use server" at the top of a file (every export becomes a server function) or as the first line of an async function inside a Server Component.
Forms: <form action={createTodo}> sends FormData; useActionState gives the returned state and an isPending flag, and useFormStatus gives the pending state to a submit button inside the form.
After the write: call revalidatePath or revalidateTag (or updateTag in Next.js 16) so the next render shows fresh data; redirect() navigates.
Arguments and results must be serializable, like Server-to-Client props.
They are for mutations; reads belong in Server Components, because the client runs actions one at a time and their results are not cached.
A route.ts file exports functions named after HTTP methods (GET, POST, DELETE) that take a Request and return a Response: a plain HTTP endpoint at that URL. Use one when something other than your own UI calls the server; use a Server Action for mutations from your own forms and buttons.
Route Handler fits: webhooks from a payment provider or GitHub, a public or mobile API, OAuth callbacks, file downloads and streams, RSS or CSV exports, and any GET endpoint, since Server Actions are POST only.
Server Action fits: a form or button in your app that changes data. It needs no URL or fetch code, works before hydration, and its revalidatePath or updateTag refreshes the page in the same round trip.
Not for your own pages: a Server Component calls the data function directly. Fetching your own Route Handler from it adds an HTTP hop, and during the build there is no server to answer.
Rules: a folder cannot hold both page.tsx and route.ts; params is a promise since Next.js 15; GET handlers are dynamic by default since 15, and caching is opt-in (export const dynamic = 'force-static' or revalidate).
The same security work as an action: authenticate and validate input. For a webhook, verify the signature over the raw body from request.text() before parsing the JSON.
proxy.ts in the project root exports one function that runs before a request reaches its route and can redirect, rewrite, set headers and cookies, or respond itself. Next.js 16 renamed middleware to proxy and runs it on the Node.js runtime; keep it to fast decisions about the request.
Good uses: sending a visitor without a session cookie to /login (an optimistic check), choosing a locale, assigning an A/B bucket, adding headers such as a CSP nonce. Fixed redirects belong in redirects in next.config.
Not real authorization: the docs rule it out for full session management or authorization. Check permissions again in each page, Route Handler and Server Action: a matcher change can skip a path, and in March 2025 CVE-2025-29927 let a forged x-middleware-subrequest header skip middleware entirely (fixed in 15.2.3).
No slow work: it runs on every matched request, prefetches included, so a database query there slows every navigation.
No shared state: it can be deployed apart from the app, so it must not rely on module globals; pass data on through request headers, cookies or the URL.
**Scope it with config.matcher**: without one it also runs for _next/static, images and public/ files, and a redirect there breaks CSS and JS.
Migration: npx @next/codemod@canary middleware-to-proxy . renames the file and the function; middleware.ts still works for the Edge runtime but is deprecated.
Make the component async and await the data directly (a database call, an ORM or fetch), with no useEffect and no client API layer. Waterfalls appear when awaits run one after another, so start independent requests together with Promise.all and let slow parts stream behind Suspense.
Sequential by accident: await getUser() followed by await getOrders() adds the latencies even though neither depends on the other.
Parent and child waterfalls: a child that starts fetching only after the parent's await finished; start the request in the parent and pass the promise down, or let each fetch independently.
Duplicate calls are fine to write: fetch calls with the same URL and options are deduplicated within one request, and React's cache() does the same for database calls.
Streaming: wrap a slow section in Suspense so the rest of the page is sent first.
Client-side data (polling, infinite scroll, data the user edits in place) still belongs to TanStack Query or SWR in Client Components.
With streaming the server sends HTML in chunks over one response: first the shell with Suspense fallbacks, then each suspended part as it resolves, as HTML plus a small inline script that swaps it into place. The user sees content before the slowest query finishes.
Mechanism: renderToReadableStream (or renderToPipeableStream in Node) flushes everything outside pending boundaries; each boundary's content arrives later in the same response.
Selective hydration: React hydrates boundaries as their HTML and code arrive, and hydrates the one the user interacts with first.
**loading.tsx** is a Suspense boundary around the page segment, so navigation shows a skeleton immediately.
Status and headers go out with the first chunk: a notFound() or an error after streaming started cannot change the status to 404 or 500, so Next.js adds a noindex meta tag instead.
Placement: one boundary per independent slow section; too few and the slowest query blocks everything, too many and the page pops in piece by piece.
Proxies and hosts that buffer responses silently disable streaming.
Hydration attaches React to server-rendered HTML and expects the first client render to produce the same markup. A mismatch happens when render output depends on something that differs between server and browser: the time, randomness, window, localStorage, the locale or invalid HTML nesting.
Typical causes: Date.now() or toLocaleString() (different time zone and locale), ids from Math.random(), typeof window branches, reading localStorage during render, browser extensions that inject attributes, a <div> inside a <p>.
What React does: it logs the difference and re-renders up to the nearest Suspense boundary (or the root) on the client, throwing the server HTML away there; that costs time and can flash.
Fixes: render the same thing on both sides and switch to the browser value after mount in useEffect; use useId for ids; pass the time or locale from the server; load browser-only widgets with next/dynamic and ssr: false from a Client Component.
**suppressHydrationWarning** silences one element's own text or attribute difference; it is meant for unavoidable cases such as a timestamp, not as a general fix.
The first render reads localStorage only in the browser, so the server renders "Dark mode" while a dark-theme user's browser renders "Light mode" during hydration, and the text differs.
The typeof window check does not make server and client agree; it is exactly what makes them disagree.
The first client render must match the server HTML; browser-only values are applied after hydration, in an effect.
Fix: start from the server-safe default and read the saved value in useEffect; the label updates right after hydration.
To avoid even that brief flip for themes, keep the choice in a cookie the server can read, or set a class on <html> from a tiny inline script before React loads and style it with CSS.
Choose a SPA when SEO and first-load speed for anonymous visitors do not matter and the app is highly interactive behind a login: an admin panel, an internal tool, an editor. It deploys as static files to any CDN, with no Node server to run.
SPA strengths: a simpler mental model (everything runs in the browser), cheap hosting, offline support with a service worker, no server and client boundary to design around.
Next.js and RSC strengths: server-rendered HTML for SEO and LCP, less JavaScript for content-heavy pages, data fetched next to the database, streaming.
Costs of RSC: a server to operate and pay for, framework lock-in, caching layers to learn and serialization rules at every boundary.
Middle ground: React Router v7 framework mode and TanStack Start give SSR and loaders without adopting Next.js; a SPA can also prerender its public pages.
create-react-app is deprecated (since February 2025); new SPAs start with Vite.
Export a static metadata object, or an async generateMetadata when the tags depend on params or data, from a page or layout; Next.js renders them as <title>, <meta> and <link> tags in the server HTML. Special files (sitemap.ts, robots.ts, opengraph-image.tsx) generate the rest.
Titles: title: { template: '%s | Shop', default: 'Shop' } in the root layout, so each page sets only its own part.
Canonical and languages: metadataBase in the root layout lets URL fields be relative, and alternates: { canonical, languages } renders the canonical link and the hreflang alternates.
No double fetch: fetch calls are memoized across generateMetadata and the page within one request; wrap database calls in React cache() for the same effect.
Merging is shallow: a page that sets openGraph replaces the layout's whole openGraph object, so shared fields such as the image disappear unless you spread them back in.
Server Components only: a "use client" page cannot export metadata; keep the page on the server and move the interactive part into a child.
Streaming metadata (Next.js 15.2+): crawlers that run JavaScript, such as Googlebot, may get the tags after the first HTML chunk, while HTML-only bots such as link previewers get them in <head>.
Tags are not enough: the content itself must be in the server HTML, and a missing page should call notFound() so it returns a real 404.
React turns each server function into an HTTP endpoint identified by an id the client sends in a POST, and anyone can call it with any arguments, not only your form. So every action must authenticate, authorize and validate its input itself, exactly like an API route.
Auth inside the action: check the session in the function; hiding the button from non-admins protects nothing, and a check in middleware or proxy.ts is not a substitute.
Authorize the object: the question is whether this user may edit this postId, not only whether someone is logged in; ids come from the client and can be changed.
Validate input with a schema (Zod, Valibot): FormData values are strings or files of any size, and TypeScript types are not checked at runtime.
Closures: values an inline action captures travel to the client and back; Next.js encrypts them, but never capture secrets.
Built-in protections: Next.js accepts only POST, compares Origin with Host against CSRF, drops unused actions from the build and makes action ids unguessable; none of that replaces auth.
Rate-limit expensive or abusable actions (sign-up, sending email).
Next.js caches at several layers: request memoization within one render, the Data Cache for results across requests, the Full Route Cache for prerendered pages and the client Router Cache. Since 15 data is not cached by default, and 16's Cache Components make caching explicit with "use cache".
The layers
Request memoization: identical fetch calls, and functions wrapped in React cache, run once per server request.
Data Cache: persisted fetch results, opted in with cache: 'force-cache' or next: { revalidate } and tagged with next: { tags }.
Full Route Cache: the HTML and RSC payload of static routes, built at build time or by revalidation.
Router Cache: RSC payloads of visited and prefetched segments kept in the browser for back and forward navigation.
What changed
Next.js 14 cached fetch by default, which surprised many teams with stale data.
Next.js 15 stopped caching fetch, GET route handlers and page segments in the client Router Cache by default; you opt in.
Next.js 16 with cacheComponents: true: everything is dynamic unless marked "use cache" at file, component or function level, with cacheLife for duration and cacheTag for invalidation; the static shell is prerendered and dynamic parts stream (Partial Prerendering).
In an interview
Say what is cached, for how long, keyed by what, and how it is invalidated; most stale-data bugs are a missing tag or a forgotten cache layer.
Invalidate the cache entries the write made stale, from the server function that did the write: revalidateTag for every cached read tagged with that data, revalidatePath for one route. In Next.js 16, updateTag expires a tag immediately, so users see their own write on the next render.
**revalidateTag(tag, profile)**: marks tagged entries stale and refetches them in the background (stale-while-revalidate); in Next.js 16 the second argument, such as 'max', is required. Fits content others will see a moment later.
**updateTag(tag) (Next.js 16, Server Actions only): expires the entries and refetches within the same request, which gives read-your-own-writes.
revalidatePath('/posts')**: invalidates one route (or everything under a layout with 'layout'), coarser than tags.
**refresh()** from next/cache in a Server Action, or router.refresh() on the client, re-renders the current route's Server Components without touching cached data.
Called inside a Server Action, these also update the client Router Cache, so the page shows the new data in the same round trip.
Tag by entity (post-42) and by collection (posts) so one write invalidates exactly what it changed.
cache(fn) from React memoizes a function for one server request: components that call getUser(id) with the same arguments share one call and one result. "use cache" persists results across requests and users until they expire or are invalidated.
**Why cache**: deep components fetch what they need without prop drilling and without duplicate database queries; fetch is deduplicated automatically, ORM calls are not.
Scope: the memo is discarded after the request, so it never serves one user's data to another.
Arguments are compared by identity for objects, so pass primitives; a new object on each call misses the cache.
Define it at module level, not inside a component, or each render gets a fresh cache.
Preload pattern: call the cached function early without awaiting it, so the query starts before the component that awaits it renders.
**"use cache"** needs serializable arguments and return values and must not read cookies() or headers() inside; read those outside and pass the values in.
currentUser lives in module scope, and a server module is loaded once per process and shared by every request: the first visitor after a start is cached, and everyone after them on that instance gets their name. It works in development because there you are the only user.
Why only sometimes: every server instance or serverless function keeps whoever reached it first until it restarts, so with several instances different users see different strangers, and each deploy reshuffles them.
The intent was per-request deduplication, which is exactly React's cache(): the memo lives for one request and is discarded after it, so the layout, the header and the page share one query without sharing it with other users.
The same class of bug: a module-level store, QueryClient or i18n instance on the server, or a module variable the layout sets and a child reads, which a concurrent request can overwrite between an await and the read.
Shared caches keyed without the user give the same symptom: a cached function (such as unstable_cache with a fixed key) that returns personal data, or a CDN rule (Cache-Control: public) on a personalized page.
Request state belongs to the request: cookies() and headers() from next/headers are scoped to the current request; read them inside the function that needs the user.
The RSC payload (the "Flight" format) is a streamed serialization of the rendered tree: elements, text, Client Component props and references to their JavaScript chunks instead of their code. On first load the client hydrates the HTML with it; on navigation it updates the tree in place.
First load: the server renders the payload to HTML for a fast first paint and also inlines the payload in <script> tags, so the client can hydrate without fetching it again.
Navigation: the router fetches only the payload for the changed segments (an RSC: 1 request in Next.js, no HTML) and reconciles it into the existing tree, so client state in shared layouts survives.
Client references: each Client Component appears as a module id plus an export name; the browser loads that chunk and renders it with the serialized props.
Streaming: pending Suspense boundaries are placeholders that later rows of the same stream fill in.
Cost: on the first load props are sent twice, in the HTML and in the payload, which is why passing whole records to Client Components bloats pages.