
Server Rendering, Static Generation, or Client Rendering: Which Fits Your App?
Static for marketing pages, server rendering for per-request data, client rendering behind a login, and both for live apps. We built one page three ways in Next.js 16.3.4 and caught a 'static' page rendering in the browser.
In this article 9 sections
Use static generation for a marketing site, server rendering for pages that change per visitor or per request, client rendering for authenticated dashboards where interactivity matters more than crawlability, and a server-rendered shell with client-rendered live parts for real-time applications. Most products need more than one, so choose per surface. We built the same page three ways in Next.js 16.3.4, plus a common mistake and its fix, and measured all five. Every route shipped almost the same JavaScript (133,633 to 134,222 bytes over the wire). What differed was whether the words were in the HTML that a crawler or a slow phone gets first. The build labeled one route ○ Static, yet it served none of its words as markup, because of a Suspense boundary we'd added to fix a build error. Nothing warned us.
TL;DR
- Static generation fits pages that show everyone the same content, and it's the cheapest to serve and cache.
- Server rendering fits pages that need cookies, headers or fresh data on every request, and Next.js keeps those pages out of shared caches.
- Client rendering fits logged-in tools where nothing needs indexing. There, the JavaScript bundle is the cost to manage.
- Live applications usually need both: a server-rendered shell that paints from the first response, with sockets, state and input handled in the browser.
- Check your raw HTML for your own words. In our lab, a page the build marked ○ Static served none of its content as markup.
When should you use SSR, SSG or CSR?
| Surface | Recommended approach | Why | What to watch |
|---|---|---|---|
| Marketing site | Static generation, with dynamic pieces only where needed | Same page for everyone, so a CDN can serve finished HTML | A client wrapper that skips server rendering or reads search params can push the whole page into the browser |
| Content or blog | Static generation plus revalidation (ISR, or 'use cache' with cacheLife) |
Content changes on publish, not per visitor, so pages regenerate without a full rebuild | Revalidating doesn't purge a CDN you run in front of Next.js yourself; self-hosting several instances needs a shared cache |
| Authenticated dashboard | Client rendering behind a login, or server rendering with session-dependent parts streamed | Nothing to index, heavy interactivity, and per-user data must stay out of shared caches | Layout auth checks don't re-run on navigation; client-only fetches wait for the bundle first |
| Live or real-time application | Server-render the shell, client-render the live parts | The shell paints from the first response; state, sockets and input run in the browser | JavaScript grows with the app and can hurt mobile responsiveness; proxies can buffer streams |
| Mixed app | Choose per route, or per component with Partial Prerendering | Public pages and the logged-in app have different audiences and cache rules | One cookie or header read makes the whole route dynamic, unless Cache Components scopes it to a Suspense boundary |
What do SSR, SSG, ISR and CSR actually mean?
- Static site generation (SSG): HTML built ahead of time and served as a file. Next.js calls it prerendering, and it's the default for components that don't read request data (Next.js glossary).
- Server-side rendering (SSR): HTML built on the server for each request, so it can use cookies, headers and fresh data (web.dev). Next.js calls this dynamic rendering.
- Incremental Static Regeneration (ISR): static pages that update without a full rebuild, regenerating in the background after a set interval, or on the next request after you invalidate them on demand (Next.js ISR guide).
- Client-side rendering (CSR): the server sends a shell and a JavaScript bundle, and the browser builds the content (web.dev).
- Streaming: HTML sent in chunks as each part becomes ready, split at
<Suspense>boundaries. The App Router streams by default (Next.js streaming guide). - Server and Client Components: a separate question, about where code runs. Server Components run only on the server and add nothing to the bundle. Client Components, marked
'use client', render to HTML on the server for the first load, then hydrate, meaning React attaches their interactivity in the browser (Next.js server and client boundary).
Key fact: "Client Component" doesn't mean "client-rendered." Content only drops out of the HTML when something stops it rendering on the server, such as gating it behind an effect or a typeof window check, loading it with next/dynamic and ssr: false, or the bailout we show below.
What happens when you render the same page three ways?
We built a small Next.js app whose routes render identical content and differ only in how it reaches the browser. Each renders a shared Article component (a heading, three paragraphs and a list) plus a marker unique to the route, such as LAB_MARKER_static, so we can check whether the content is in a response. There's no CSS, and the empty next.config.ts leaves Cache Components off, which is the default.
The static route is a plain Server Component, prerendered once at build:
// app/static/page.tsx
import { Article } from '../_lab/Article'
export default function StaticPage() {
return (
<main>
<Article marker="LAB_MARKER_static" />
</main>
)
}
The server route awaits connection(), which tells Next.js to wait for a real request before rendering (connection reference):
// app/server/page.tsx
import { connection } from 'next/server'
import { Article } from '../_lab/Article'
export default async function ServerPage() {
await connection()
return (
<main>
<Article marker="LAB_MARKER_server" />
</main>
)
}
The client route renders this Client Component, which swaps the content in after it mounts. Effects never run on the server, so the server HTML only holds the placeholder:
'use client'
// app/client/ClientArticle.tsx
import { useEffect, useState } from 'react'
import { Article } from '../_lab/Article'
export function ClientArticle() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return <p>Loading</p>
return <Article marker="LAB_MARKER_client" />
}
Two more routes test the accidental client-rendering trap, covered below.
How did we measure it?
- HTML availability: we fetched each route with curl, stripped every
<script>block, and counted the marker in what was left. A marker only inside a script is data for JavaScript, not markup. - JavaScript: the bytes
next startsent for every script (gzip, except files under 1,024 bytes, which it sends uncompressed), minus the noModule polyfill modern browsers skip, cross-checked in Chromium. Next.js 16 no longer prints First Load JS at build (Next.js 16 upgrade guide). - Caching and speed: headers on two consecutive requests, a conditional request with the ETag, and the median time to first byte (TTFB) of 15 requests after two warm-ups.
To reproduce the setup, create an empty folder with the three files above and the trap files from the section below. Add a root layout that renders <html><body>{children}</body></html>, an Article component of your own, and a page.tsx for each of /client, /trap and /trap-scoped that wraps ClientArticle or Article in <main>. The snippets are trimmed from our lab source and your content will differ, so expect different byte counts and compare the gaps between routes. Then run:
npm install next@16.3.4 react@19.3.0 react-dom@19.3.0
npm install -D typescript@5.9.3 @types/react@19.3.0 \
@types/react-dom@19.3.0 @types/node@24.13.4
npx next build
npx next start -p 4311
# second terminal: marker count in markup only
curl -s http://localhost:4311/static \
| perl -0777 -pe 's/<script\b[^>]*>.*?<\/script>//gis' \
| grep -o LAB_MARKER_static | wc -l
What were the results?
HTML bytes are uncompressed; the gzip bytes sent are in parentheses. TTFB is the median of 15 localhost requests in the first of our three runs. Across the three runs, the four prerendered routes' medians ranged from 0.446 to 0.636 ms, so their order is noise; only /server was consistently slower.
| Route | Build symbol | Content in markup? | HTML bytes (gzip sent) | JS bytes sent (files) | Cache-Control | TTFB median |
|---|---|---|---|---|---|---|
| /static | ○ Static | Yes | 6,810 (1,986) | 133,633 (6) | s-maxage=31536000 | 0.475 ms |
| /server | ƒ Dynamic | Yes | 6,822 (2,324, streamed) | 133,633 (6) | private, no-cache, no-store, max-age=0, must-revalidate | 1.283 ms |
| /client | ○ Static | No | 5,398 (1,646) | 134,222 (7) | s-maxage=31536000 | 0.636 ms |
| /trap | ○ Static | No, only inside a script | 6,709 (1,998) | 134,074 (7) | s-maxage=31536000 | 0.597 ms |
| /trap-scoped | ○ Static | Yes | 7,284 (2,094) | 134,000 (7) | s-maxage=31536000 | 0.456 ms |
Once JavaScript ran, every route showed the full article in headless Chrome. The differences are in what arrived first.
What do the numbers mean?
The JavaScript barely moved. The static page has no interactivity, yet it loaded six scripts totaling 133,633 compressed bytes: the framework runtime, which every route carried. The client route added one 589-byte chunk holding the content. A real client-rendered app differs: its JavaScript grows with the app and can hurt mobile responsiveness (web.dev).
HTML availability is the real split. The client route's server HTML was just <main><p>Loading</p></main>. A crawler that doesn't run JavaScript sees only that placeholder (Next.js server and client boundary). Google renders JavaScript once a page clears its rendering queue, but still recommends server-side rendering or prerendering because not every bot runs JavaScript (Google Search Central). Content managed by JavaScript can also delay Largest Contentful Paint (web.dev).
Key fact: The ○ Static symbol describes how the HTML is produced, not whether your content is in it. Our client route and our trap route both built as ○ Static, the same symbol as the genuinely static page.
Caching split cleanly by strategy. The prerendered routes returned s-maxage=31536000, x-nextjs-cache: HIT and an ETag, and a repeat request with that ETag got a 304 with no body. The server route returned private, no-cache, no-store, no ETag, and a full 200 every time. Both match what Next.js documents (CDN caching guide). The script we checked under /_next/static was served as public, max-age=31536000, immutable, and loading /server after /static in one tab pulled all six shared scripts from the browser cache.
Server rendering cost more time, but not much here. The server route's median TTFB was 1.283 ms, roughly two to three times the prerendered routes' 0.456 to 0.636 ms, and it was the slowest route in all three runs. The gap stayed under 1 ms, because the route fetches no data. Read it as a direction, not a size.
What was the test environment?
We measured on September 10, 2026, using an Apple M4 Max (16 cores, 64 GiB) running macOS 26.6.2, with Node 24.10.0 and Next.js 16.3.4 built with Turbopack. We installed react and react-dom 19.3.0, but the App Router renders with the React build bundled inside Next.js, not the installed copy. It was a production build (next build, then next start) on localhost over HTTP/1.1, measured with curl 8.7.1 and headless Chrome 152. We ran the byte and timing measurements three times: twice against one build, then again after a clean install and fresh builds, which also repeated the build, header, 304 and after-JavaScript checks. Byte counts matched, apart from a 1-byte change in compressed HTML from the new build ID.
- Localhost only: no network latency, TLS, CDN or phone CPU, so TTFB is server work, not what a visitor feels.
- One busy machine: 17 unrelated Next.js server processes were running, and noise among the prerendered routes was as large as the gaps between them.
- Small pages: the server route fetches no data, and the client route makes no second request, a best case.
- Not covered: paint metrics, Lighthouse, Brotli, ISR and Cache Components.
What makes a Next.js page dynamic instead of static?
In the default Next.js 16 model, the one our lab used, a route renders per request once anything in it reads cookies(), headers(), searchParams or draftMode() (Next.js glossary), awaits connection(), or fetches with cache: 'no-store' (fetch reference). One cookie read in a layout is enough (cookies reference). That's right for a dashboard. On a marketing page, it throws away a file a CDN could have served. A cookie read in the root layout, often added to set a theme class on <html>, makes every route dynamic. An inline script that sets the theme before paint avoids it (Cache Components migration guide).
Next.js's docs spell out the cost of a route-level boundary: a mostly static page with one dynamic element, like a greeting or a live price, has to become fully dynamic or fetch that element in the browser after load (Next.js rendering philosophy). Cache Components moves the boundary to the component. It's opt-in in Next.js 16: set cacheComponents: true (Next.js 16 release post). A static shell then ships at once, dynamic parts stream in behind <Suspense>, and reading cookies inside a boundary no longer makes the whole route dynamic (Next.js caching docs). In the Next.js example, a per-request promo banner inside <Suspense> turns a ○ Static route into ◐ Partial Prerender (public pages guide).
Key fact: Without Cache Components, one request-data read makes the whole route dynamic. With it, the boundary moves to the component, and where you read the data decides how much of the page stays static.
To keep that static shell as large as possible:
- Push request data down. Awaiting
params,cookies()or a fetch at the top of a page keeps everything below it out of the shell. Pass the promise down and resolve it inside<Suspense>(streaming guide). - Cache on purpose. Data is dynamic by default under Cache Components, and you choose what to cache with
'use cache'andcacheLife. A plain'use cache'function can't read cookies or headers, so read them outside and pass in only what it needs (use cache reference). Pass a user id, never a token or a raw email, because cache keys are stored in plain text. For data that has to read the session itself,'use cache: private'reads cookies directly and keeps the result in the browser (authentication with Cache Components).
Three limits catch teams after launch:
- Revalidation doesn't purge a CDN you put in front of Next.js yourself.
revalidatePathandrevalidateTagclear only the Next.js cache, so a CDN caching ons-maxagekeeps serving its copy until you purge it through the CDN's API (CDN caching guide). If you run several instances yourself, the default cache is per instance, so only the one that got the call is cleared unless you add a shared cache handler (ISR guide). Hosts that integrate Next.js caching, such as Vercel, purge their own CDN when you revalidate (Vercel ISR docs). - Layout auth checks don't re-run on navigation. Authorize next to the data, and use Proxy (the Next.js 16 name for Middleware) only for quick cookie checks (authentication guide).
- A streamed page has already said 200. A
notFound()after streaming starts can't change that status, so Next.js adds a noindex tag instead. Run the check before anything streams: in the page or layout, outside any<Suspense>boundary, including the one aloading.jsfile adds. Awaiting the lookup first is fine; the docs' own example does (streaming guide).
How does a static page end up rendering in the browser?
This is the performance failure we hit most often in our own work: a page meant to arrive as finished HTML falls back to rendering entirely in the browser, so the main content appears only after the JavaScript bundle loads. In our cases, the cause was a client-side analytics or provider component wrapped around the whole page tree in a way that stopped its children rendering on the server. Wrapping alone doesn't do that, since Client Components still render to HTML on the server. Loading the wrapper with next/dynamic and ssr: false does, and so does reading search params inside it once a Suspense boundary sits above the page.
Our lab reproduces the second of those. The /trap layout wraps the page in an attribution provider that reads ?utm_source=:
'use client'
// app/_lab/UtmProvider.tsx
import { createContext, type ReactNode } from 'react'
import { useSearchParams } from 'next/navigation'
const UtmContext = createContext<string | null>(null)
export function UtmProvider({ children }: { children: ReactNode }) {
const source = useSearchParams().get('utm_source')
return <UtmContext value={source}>{children}</UtmContext>
}
Why does the build fail with "useSearchParams() should be wrapped in a suspense boundary"?
As first written, the layout returns <UtmProvider>{children}</UtmProvider> with no Suspense boundary anywhere. next build compiled and type-checked, then stopped during static generation with exit code 1 (stack trace and links trimmed):
⨯ useSearchParams() should be wrapped in a suspense boundary at page "/trap".
Error occurred prerendering page "/trap".
Export encountered an error on /trap/page: /trap, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
The error names the page, not the provider, and the stack frames are minified. The docs also say useSearchParams() doesn't suspend in development (useSearchParams reference), so it can look fine in next dev. We didn't test that part.
Why does adding a Suspense boundary leave the HTML empty?
The obvious fix, a <Suspense> boundary around the provider, built with no warning and listed /trap as ○ Static:
// app/trap/layout.tsx, the version that builds
import { Suspense, type ReactNode } from 'react'
import { UtmProvider } from '../_lab/UtmProvider'
type Props = { children: ReactNode }
export default function TrapLayout({ children }: Props) {
return (
<Suspense fallback={null}>
<UtmProvider>{children}</UtmProvider>
</Suspense>
)
}
It served a fully cacheable page with the same headers and 304 behavior as /static. Its entire body markup was this, split across lines for width:
<div hidden=""><!--$--><!--/$--></div>
<!--$!--><template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
</template><!--/$-->
No heading, no paragraphs. The words existed only inside the inline script carrying React's component payload. That's documented behavior: on a prerendered route, useSearchParams() client-renders everything up to the nearest Suspense boundary, and the fallback is what goes in the HTML (useSearchParams reference). Our boundary sat above {children}, so the whole page went with it.
Key fact: A Suspense boundary added to silence this error decides how much of your page renders in the browser. Put it around the smallest thing that reads search params, never around the page.
How do you read search params without client-rendering the page?
/trap-scoped records the same attribution with a childless component beside the page, in its own boundary. It stores the value in sessionStorage rather than sharing it through context:
'use client'
// app/_lab/UtmTracker.tsx
import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
export function UtmTracker() {
const source = useSearchParams().get('utm_source')
useEffect(() => {
if (source) sessionStorage.setItem('utm_source', source)
}, [source])
return null
}
// app/trap-scoped/layout.tsx
import { Suspense, type ReactNode } from 'react'
import { UtmTracker } from '../_lab/UtmTracker'
type Props = { children: ReactNode }
export default function TrapScopedLayout({ children }: Props) {
return (
<>
<Suspense fallback={null}>
<UtmTracker />
</Suspense>
{children}
</>
)
}
It builds as ○ Static with the full article in the HTML. One BAILOUT_TO_CLIENT_SIDE_RENDERING template remains, but it stands in only for the tracker, which renders nothing. The JavaScript is effectively unchanged: 134,000 bytes sent against 134,074 for the broken version. Next.js recommends the same shape: render providers as deep in the tree as you can (Next.js server and client components) and wrap the smallest subtree that calls the hook (missing Suspense error page). That's how we build now: analytics mounts beside the page, never around it, and waits until the page is idle or the visitor interacts.
If components inside the page need the value while rendering, read it with useSearchParams inside each one's own small boundary. Or keep the provider but stop it calling useSearchParams() during render: read window.location.search in an effect instead. The page stays in the HTML, and consumers get null until hydration.
Should a marketing site and a logged-in app use different rendering?
upforge.io is built to be static wherever a page can be. It's public, the same for everyone, and has to be readable by crawlers and fast on a phone. Our homepage prerenders, and analytics sits beside the page rather than around it. Step 1 of the check below, run on our homepage, returns its headings and copy as markup. Which pages can be files and which truly need the server is the first call we make in our web development work.
app.sonor.io is a client-rendered React app behind a login. It's our Sonor platform's dashboard, a Vite single-page app. Its first response is a small HTML shell with an empty root element, and the browser builds the rest. Nothing behind the login needs indexing, the views are heavily interactive, and per-user data shouldn't sit in a shared cache anyway. The tradeoff is the first load, which needs the bundle before anything shows, so code-splitting and a JavaScript budget matter more there (web.dev).
A live application sits between the two: server-render the shell, then hand the live parts to the browser. Client-only fetching waits for hydration and then a request, so pass initial data from a Server Component where you can (Next.js data fetching guide). Check your hosting too: nginx buffers responses by default (send X-Accel-Buffering: no to stop it), and some CDNs and load balancers do the same, turning a stream back into one late burst (streaming guide).
In Next.js, the choice isn't permanent: an app can start as a static site or a strict single-page app and add server features later (Next.js SPA guide). If you're still choosing between a client portal, a scheduling system and an internal dashboard, our guide to which custom app to build first walks through it.
How can you tell if a page is server-rendered or client-rendered?
Pick five pages: the homepage, a top service page, a blog post, your main lead-form page, and one logged-in page if you have one.
- Look for your words in the raw HTML. Run the curl pipeline above with your page's URL in place of
http://localhost:4311/staticand a quoted phrase from the page in place ofLAB_MARKER_static. Pick the phrase from inside one plain sentence that doesn't cross a link, bold text or a value filled in by code, since React separates those with tags or<!-- -->comments. Avoid apostrophes and ampersands too, which HTML may encode. A count of 0 then means it isn't in the markup. If View Source (not Inspect, which shows the DOM after JavaScript) still finds it inside a<script>that isn't JSON-LD structured data, you have the /trap result. - Turn JavaScript off and reload. A statically rendered page keeps most of its content and features (web.dev). A client-rendered one goes blank or shows a loading state. So does a streamed section of a server-rendered page, because React swaps streamed content in with a small inline script. Check any loading state against step 1.
- Read the route table.
next buildmarks routes ○ Static, ● SSG, ◐ Partial Prerender or ƒ Dynamic (Next.js building guide). A marketing page showing ƒ Dynamic has something to find: a request-data read, aconnection()call, ano-storefetch or aforce-dynamicsetting. A ○ Static label doesn't prove the content is in the HTML, so do step 1 as well. - Check the cache headers.
private, no-storeon a page that's the same for everyone means your CDN can't help it. Onnext start,x-nextjs-cacheshows whether a page came from cache (ISR guide). - Run mobile Lighthouse against production. Its default mobile profile simulates a slow mobile network and a throttled CPU (Lighthouse throttling docs). Never test
next dev, where pages render on demand and are never cached (Next.js caching guide). - Measure JavaScript in a browser. In the Chrome DevTools Network panel, click the JS filter, tick Disable cache, reload, and read the transferred figure in the status bar (Chrome DevTools). Or paste the snippet below into the console.
performance.getEntriesByType('resource')
.filter((e) => new URL(e.name).pathname.endsWith('.js'))
.reduce((sum, e) => sum + e.encodedBodySize, 0)
It adds up compressed bytes for scripts from your own domain. Scripts from other domains report 0 unless their server sends a Timing-Allow-Origin header, so read those in the Network panel. Our website audit checklist covers the rest of the baseline, including Core Web Vitals thresholds.
Then fix in this order: public content missing from the HTML (find the wrapper or effect that pushed it into the browser), then public pages rendering per request for no reason (move or remove the request-data read), then slow logged-in apps (stream the session-dependent parts, or split the bundle).
What should you do next?
If a page isn't doing what its build label says, start with whatever wraps it: a provider, an analytics component or a mounted-state gate. In our lab, the fix was a layout change plus a small childless tracker that replaced the wrapping provider. A bigger mismatch, like a public page that renders per request for no reason, is a strategy call, so go back to the table at the top and choose per surface.
The Sonor platform case study shows both halves in one system: server-rendered SEO and blog modules for public Next.js sites, and a React dashboard behind a login where businesses run their analytics, CRM and forms.
The details that matter.
Is server-side rendering better for SEO than client-side rendering?
Server and static rendering put your content in the first HTML response, which any crawler can read, including ones that don't run JavaScript. Google does render JavaScript, but pages wait in a rendering queue first, and Google still recommends server-side rendering or prerendering. Check the raw HTML rather than the build label, since a page Next.js marks static can still ship its content only inside a script. Neither approach guarantees rankings. Server or static rendering just removes one reason a crawler might miss your content.
Link to this answer ↗Does a Client Component make a page client-rendered?
No. In Next.js, Client Components render to HTML on the server for the first load, then hydrate in the browser. Content only drops out of the HTML when something stops it rendering on the server, such as a mounted-state effect, a typeof window check, loading it with next/dynamic and ssr: false, or a useSearchParams() bailout with its Suspense boundary wrapped around the whole page.
Link to this answer ↗Should an authenticated dashboard be a Next.js app or a Vite single-page app?
Both can work. A Vite single-page app suits a tool where every screen sits behind a login, which is how we built app.sonor.io. Next.js suits a dashboard that benefits from server features in the same codebase, like fetching data close to the source or keeping secrets off the client, and it can start as a strict single-page app and add those later.
Link to this answer ↗What is Partial Prerendering in Next.js 16?
Partial Prerendering serves a prerendered static shell immediately and streams the dynamic parts, wrapped in Suspense, as they're ready. In Next.js 16 it's opt-in: setting cacheComponents to true in next.config makes it the default, and routes that use it show ◐ Partial Prerender in the build output. Our lab ran the default configuration, so it didn't measure this mode.
Link to this answer ↗How often should a statically generated page revalidate?
Next.js recommends a long interval, such as an hour rather than a second, paired with on-demand revalidation when content changes. Background regeneration uses server compute each time it runs. If you've put your own CDN in front of Next.js, on-demand revalidation doesn't clear the CDN's copy, so purge it through your CDN's API as well. Hosts that integrate Next.js caching, such as Vercel, do that purge for you.
Link to this answer ↗Is Next.js server-side rendered by default?
Not on every request. In the default App Router setup, Next.js prerenders a route at build time, which is static generation, unless something in it reads cookies(), headers() or searchParams, awaits connection(), or fetches with cache: 'no-store'. Those routes render per request instead. Run next build and read the route table: ○ means prerendered and ƒ means rendered on each request, though ○ alone doesn't prove your content is in the HTML.
Link to this answer ↗About this article
- From the Build
- Written from our own project work.More from the series
- Last updated
- September 26, 2026
- Corrections
- Spotted an error? Tell us.
