Skip to main content
ASoc
Tutorial

React Server-Side Rendering, Measured Against a Real App Router Build

A fresh build of this site: 246 prerendered routes, 8 dynamic ones, and the one live chat widget that still has to hydrate on the client.

The ASoc Team9 min read

Server-side rendering means generating a page's HTML on a server instead of in the browser, so the first response is content rather than an empty <div id="root"> waiting on JavaScript. Plain React ships no server at all — you add renderToString and a Node process yourself. Next.js's App Router does it by default: every page is a Server Component unless you opt out, and no one on the team writes a render call.

We measured what that default actually produces. A fresh production build of this site — 111 catalog products, 54 blog posts, and the rest of the marketing surface — comes out to 246 prerendered pages and 8 dynamic routes, with a single line in the build log (ƒ Proxy) covering the one piece of server logic that touches every request. Here is what that number is made of, and where a genuinely client-rendered widget still has to live inside it.

What "SSR" means depends on which React you're holding

The term predates the App Router by a decade, and most guides you'll find are still describing the original shape: a Node/Express server calling renderToString(<App />) on every request, sending HTML, then hydrating in the browser with hydrateRoot. That is real SSR, and it is also almost entirely infrastructure you now get for writing a file in the right folder.

Hand-rolled SSR (Express + renderToString)Next.js App Router (default)
Server code you writeA render loop, routing, data fetchingNone — page.tsx is the server code
Rendering unitWhole page tree per requestPer-route, cacheable at build or request time
Static outputNot automaticPrerendered at build when nothing is request-scoped
StreamingManual (renderToPipeableStream)Built in — loading.tsx, <Suspense>
Client JS shippedEverything, then hydratedOnly what a Client Component needs
Data fetchingFetch, then pass down as propsawait directly in a Server Component

The rightmost column is the reason "how do I add SSR to my React app" increasingly has the same answer as "use a framework that already renders on the server" — which is a different question than the one most SSR tutorials are answering.

Reading our own route table

next build prints exactly what rendered where. Run it and you get three symbols, not two:

Route (app)
┌ ○ /                          (Static — prerendered, no params)
├ ƒ /api/download               (Dynamic — server-rendered per request)
├ ○ /blog
├ ● /blog/[slug]                (SSG — prerendered per generateStaticParams)
│ ├ /blog/lemonsqueezy-vs-stripe-digital-products
│ └ [+51 more paths]
├ ƒ /dashboard
├ ƒ /login
├ ○ /templates
├ ● /templates/[slug]
│ └ [+108 more paths]
└ ○ /terms

ƒ Proxy (Middleware)

Tallied from that output: 27 static routes, 219 statically-generated ones (54 blog posts, 54 matching OG-image endpoints, and 111 product pages), and 8 dynamic routes/api/download, the LemonSqueezy webhook, /auth/callback, /dashboard, /dashboard/settings, /login, /reset-password, /signup. 246 prerendered against 8 that need a server per request. Everything that doesn't need a signed-in user or a mutation is static; everything that does is server-rendered on demand. Nobody wrote a decision tree for that split — it falls out of which files call cookies(), read searchParams, or hit the database per request. The full list of what triggers it is its own post.

That table is also the answer to "does SSR hurt SEO or help it": every one of those 246 prerendered pages ships complete HTML to a crawler with zero JavaScript execution required, which is the property hand-rolled SSR exists to provide and the App Router gives you for the routes that qualify automatically.

The one page that has to be client-rendered anyway

Not everything can be a Server Component, and pretending otherwise produces worse code than just admitting where the client is genuinely required. ASoc Ally is a marketing site for an AI support chatbot built around a live chat-widget preview — an interactive demo the visitor types into. That widget cannot be a Server Component: it holds per-keystroke state, and there is no server round-trip that makes sense for "show a typing indicator."

The pattern that keeps the rest of the page server-rendered while that one widget is interactive:

// Server Component — no directive, runs on the server, ships no JS for this part
export default function HeroSection() {
  return (
    <section>
      <h1>Instant answers, 90+ languages</h1>
      <ChatWidgetDemo /> {/* the one Client Component on the page */}
    </section>
  );
}
"use client";
// Client Component — this file and its imports ship to the browser
import { useState } from "react";

export default function ChatWidgetDemo() {
  const [messages, setMessages] = useState<Message[]>([]);
  // ...typing state, send handler
}

The server still renders the hero copy, the feature grid, the pricing table and everything else in that HTML response — a crawler and a slow connection both see the full page immediately. Only the widget itself waits on hydration, and only the widget's code ships to the browser. That is the App Router's actual pitch on SSR: not "the whole page is server-rendered" or "the whole page is client-rendered," but a boundary drawn per component, decided by what genuinely needs interactivity rather than by which framework you reached for.

Hydration is the part SSR tutorials gloss over

Sending HTML first is only half the deal. The browser still has to attach React's event handlers to that markup before anything is clickable — that step is hydration, and it is where SSR's second-order costs live.

Two things about it are easy to miss:

  • Hydration re-renders, it doesn't just "wake up." React walks the same tree again on the client to build its internal representation and confirm it matches the server's HTML. For a static page that tree is cheap. For ChatWidgetDemo above, hydration is the point where useState actually starts existing — before it, the widget is inert markup a crawler can still read.
  • Server and client must render the same thing, or React throws away and rebuilds. Anything that differs between the two — Date.now(), Math.random(), reading localStorage during render — produces a hydration mismatch, and the fix is usually to defer the client-only value to an effect rather than read it during the render both sides share. The specific trap in this codebase, and the hook that avoids it, is its own post.

The App Router's Server Components sidestep most of this by never hydrating at all — their output is static HTML with no client-side counterpart, so there is nothing to reconcile. Hydration is a cost you pay per Client Component, not per page, which is the other half of why pushing "use client" down to the smallest necessary leaf matters: every component above that boundary skips hydration entirely.

Mistakes and how they show up

MistakeSymptomFix
"use client" at the top of a whole pageEvery child ships to the browser, including static copyPush the directive down to the interactive leaf
Fetching data in a useEffect inside a Server Component treeContent flashes in after load; no HTML for crawlersawait the fetch directly in the Server Component
Assuming SSR means "no client JS"Surprise when an interactive widget still needs hydrationSSR describes the first render, not the whole page's runtime
Comparing "SSR vs SSG" as competing choicesConfusion about which to pickIn the App Router they're the same code path — SSG is SSR done at build time instead of per request
Treating renderToString docs as currentBuilding infrastructure a framework already gives youCheck whether the App Router's default already covers it
No loading state for a slow Server ComponentBlank tab until the whole tree resolvesloading.tsx streams a fallback in immediately

Frequently asked questions

Is Next.js's App Router "real" SSR, or something else? It's SSR, plus two things classic SSR didn't have: static generation at build time for routes with no per-request data, and streaming so slow parts of a page don't block fast ones. The mental model — HTML first, hydrate after — is unchanged.

Do I still need renderToString if I'm on Next.js? No. It's the primitive the framework is built on, not something you call yourself. Reaching for it inside a Next.js app almost always means a Server Component would have done the job with less code.

Does SSR make my Time to First Byte worse than a static site? Only for the routes that are actually dynamic. A prerendered route serves from the same static asset a fully static site would; you pay a per-request render cost only on the 8 routes here that need one, not on the 246 that don't.

Can a Server Component and a Client Component share state? Not directly — a Server Component's output is HTML, not a live React tree the browser can call back into. State that both need to see lives in the Client Component, seeded by a prop the Server Component passed down once. Where that boundary should sit is the more general question.

Templates that ship the server/client split already decided

ASoc Ally is the chatbot marketing site above — a live widget preview inside an otherwise fully static page. ASoc Amplify is a social-media-management SaaS site with a stats band and marketing-tools grid, entirely server-rendered with no client-state widget to isolate. ASoc Atelier is a designer's portfolio and studio site — a case-study grid and booking funnel that ship as static HTML end to end.

Browse the full set of Next.js landing page templates or the Tailwind landing page templates. For what decides whether a given route in the App Router ends up static or dynamic, the trigger list is documented here.

Keep reading

Tutorial10 min read

A React Sidebar in 60 Lines and Zero JavaScript

Sticky positioning without a scroll listener, aria-current for the active row, and the hard-coded active-state bug that only appears when you add a second page.

Read more
Tutorial9 min read

React Suspense: 376 Prerendered Pages, Zero Boundaries

Suspense buys streaming, and a page rendered at build time has nothing left to stream. Zero boundaries and zero loading.tsx here, plus the useSearchParams de-opt everyone reports wrong.

Read more