DEV Community

Cover image for Client-Side vs Server-Side Rendering: How Each Works and Migrating Both Ways
Emmanuel R
Emmanuel R

Posted on Originally published at cobuildx.ai

Client-Side vs Server-Side Rendering: How Each Works and Migrating Both Ways

Client-side rendering builds the page in the browser. Server-side rendering builds it on the server. Here's how each one works, where each one bites, and the playbooks we use to move a React app from CSR to SSR, and from SSR back to CSR.

Every web page has to be turned into HTML somewhere. Rendering is just the answer to one question: where does that happen, in the user's browser or on your server?

There are two answers, client-side rendering (CSR) and server-side rendering (SSR), and neither one is "the old way". Teams move in both directions: a React SPA that suddenly needs SEO moves to SSR, and a Next.js app that turned into a logged-in dashboard moves back to a plain SPA to cut servers and complexity.

So this post goes in four parts: client-side rendering on its own, server-side rendering on its own, then migrating CSR → SSR, and migrating SSR → CSR. The examples come from the React apps in our migratex-examples repo, which are all client-rendered today.

The Short Version

Client-side (CSR) Server-side (SSR)
Where HTML is built In the browser, by JavaScript On the server, per request (or at build time)
What the first response holds An empty <div id="root"> and a script tag The finished page
First thing the user sees A blank page or a spinner Real content
Hosting Static files on any CDN A Node (or edge) server, or a build step
Best at Long sessions behind a login Public pages that must load fast and rank
Main risk Slow first load, invisible to crawlers Code that assumes a browser, hydration mismatches

Both end with the same interactive React app in the browser. The real difference is what the user gets before your JavaScript runs.

Client-Side Rendering

How it works

With CSR, the server is just a file host. It sends the same small index.html for every URL, and the browser does all the work:

  1. The browser asks for /article/hello-world and gets back index.html with an empty <div id="root">.
  2. It downloads and runs the JavaScript bundle.
  3. React starts, the router reads the URL, and the page asks the API for the article.
  4. When the JSON arrives, React builds the HTML in the browser. Only now does the user see the article.

Our Ember-to-React blogging app is a typical one. Its whole entry point is a createRoot call in main.tsx:

import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  <App />
);
Enter fullscreen mode Exit fullscreen mode

Everything after that runs in the browser. Pages fetch their own data with TanStack Query hooks like useArticle(slug), and the login token lives in localStorage. After the first load, clicking a link never goes back to the server for HTML. The router swaps components and fetches only the JSON it needs.

Where it shines

  • Hosting is boring. The build is a folder of static files. Put it on any CDN and it scales for free.
  • Navigation is instant. Once loaded, moving between pages feels like a desktop app.
  • One environment. Your code always runs in a browser, so window, localStorage and document are always there.
  • A clean API boundary. The frontend only talks to a JSON API, which mobile apps and partners can share.

Where it costs you

  • Slow first load. Nothing shows until the bundle downloads, runs and fetches data. On a cheap phone over a weak network, that's seconds of white screen.
  • Crawlers and link previews see nothing. Google can run JavaScript, slowly and not always. Slack, LinkedIn and X previews mostly don't, so every shared link shows the same generic title.
  • Request waterfalls. Code loads, then data loads, then a child component loads more data. Each step waits for the one before.
  • The bundle only grows. Every feature adds JavaScript that every visitor downloads before seeing anything.

Server-Side Rendering

How it works

With SSR, the server runs your React components first. It fetches the data, renders the page to an HTML string and sends it. The user sees the article right away. Then the same JavaScript loads in the browser and hydrates the page: React walks the existing HTML, attaches event handlers, and takes over from there.

Under every framework, it comes down to two calls, one on each side:

// server: build the HTML for this request
import { renderToPipeableStream } from 'react-dom/server';

const article = await api.getArticle(slug);
renderToPipeableStream(<App article={article} />).pipe(res);

// browser: adopt the HTML that is already there
import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document.getElementById('root')!, <App article={window.__DATA__} />);
Enter fullscreen mode Exit fullscreen mode

Notice the browser does not fetch the article again. The server sends the data along with the HTML (window.__DATA__ here), so both sides render from the same input. In practice a framework does this wiring for you: Next.js or React Router in framework mode (the Remix successor).

SSR is a family

"Server-side" covers a few strategies. The difference is when the HTML is built:

Strategy HTML is built Good for
Static generation (SSG) Once, at build time Docs, marketing pages, blog posts
Incremental regeneration (ISR) At build, then refreshed in the background Product pages, large catalogs
Per-request SSR On every request Personalized or fast-changing pages
Streaming SSR Per request, sent in chunks as data arrives Pages with one slow section
React Server Components On the server, and some components never ship JS at all Content-heavy apps with small interactive parts

Most real apps mix them: static marketing pages, per-request article pages, and a client-rendered editor behind the login.

Where it shines

  • Content on first paint. The user reads the page while the JavaScript is still loading.
  • Crawlers and previews just work. Every URL returns real HTML with its own title and meta tags.
  • Data is fetched close to the API. The server talks to your backend over a fast network, often in parallel, instead of a phone making the same calls over 4G.
  • Secrets stay on the server. API keys and database calls in server code never reach the browser.

Where it costs you

  • Your code now runs in two places. Anything that touches window, document or localStorage while rendering crashes on the server.
  • Hydration mismatches. If the server and browser render different HTML (a timestamp, a random id, a saved theme), React warns and may throw away the server HTML.
  • You run servers again. Per-request SSR needs a Node or edge runtime, with its cold starts, scaling and monitoring.
  • Fast to see is not fast to use. The page looks ready before hydration finishes, so early clicks can do nothing. Heavy pages still pay the full JavaScript cost.

Timeline of the first load. CSR: empty HTML, download JS, run JS, fetch data, then content appears, all in the browser. SSR: the server fetches data and renders HTML, the user sees content as soon as the HTML arrives, then JS downloads and hydrates the page

Before You Migrate: Do You Need To?

Rendering is an all-or-nothing choice less often than people think. Two cheaper fixes cover a lot of cases:

  • Only link previews are broken? Inject per-page meta tags at the edge. This very site is a Vite SPA, and a small Cloudflare Pages middleware rewrites the <title> and Open Graph tags for each URL before the HTML goes out. No SSR needed.
  • Only a few public pages need to be fast and indexable? Prerender those routes to static HTML at build time and leave the rest of the app client-rendered.

Migrate the rendering model when the problem is the whole app: first-load speed on real devices, SEO across thousands of pages, or (going the other way) servers and complexity you no longer need.

Migrating CSR → SSR

This is the "our SPA needs SEO and a faster first load" direction. You don't need a rewrite. Your components stay React components. What changes is where data is fetched, where auth lives, and which code is allowed to touch the browser. Here's how each piece of a client-rendered app maps:

CSR (today) SSR (target) From our repo
createRoot(...).render(<App />) Framework entry with hydrateRoot main.tsx
useQuery fetching in the browser Route loader (or server component) fetches; the client cache is seeded from it queries.ts
Token in localStorage HttpOnly session cookie the server can read SessionContext.tsx
Theme read from localStorage on render Theme in a cookie, or a tiny inline script before paint useTheme.ts
DOMPurify on the rendered markdown A sanitizer that runs without a DOM, on the server too markdown.ts
<RequireAuth> redirecting in the browser A redirect in the loader, before any HTML is sent RequireAuth.tsx

The order we follow:

  1. Pick the framework by your router. Already on React Router? Its framework mode keeps your route tree and adds loaders. Starting fresh or want Server Components? Next.js.
  2. Turn SSR on with no data first. Get every route rendering on the server, even if it shows a loading state. This flushes out all the browser-only code in one pass.
  3. Move data fetching into loaders, route by route. Start with the public, SEO-critical pages: article, profile, home feed.
  4. Move auth into a cookie. Until the server knows who the user is, every logged-in page can only render as "logged out".
  5. Add per-page titles and meta tags. That's half the reason you came.
  6. Measure with real numbers. Compare Largest Contentful Paint and Interaction to Next Paint before and after, on a mid-range phone.

Step three in practice. In React Router framework mode, the article route fetches on the server and the component just reads the result:

// routes/article.tsx
export async function loader({ params }: Route.LoaderArgs) {
  return { article: await api.getArticle(params.slug) };
}

export function meta({ data }: Route.MetaArgs) {
  return [{ title: data.article.title }];
}

export default function Article({ loaderData }: Route.ComponentProps) {
  return <ArticleView article={loaderData.article} />;
}
Enter fullscreen mode Exit fullscreen mode

If you'd rather keep TanStack Query, prefetch on the server with queryClient.prefetchQuery and pass the cache down with dehydrate / HydrationBoundary. Your existing useArticle(slug) hooks then find their data already in the cache and don't fetch again.

Meme in two panels. Works on my machine: localStorage.getItem('token') inside a component, the SPA has always run in a browser. Then the server renders it: ReferenceError, localStorage is not defined

Gotchas we hit

A client-rendered app quietly assumes it's in a browser everywhere. Each of these lines was fine yesterday:

  1. localStorage during render. SessionContext reads the token in a useState initializer, which also runs on the server. The try/catch around it hides the crash, but then the server always renders "logged out" and the browser renders "logged in": a mismatch. The fix is a cookie the server can read, not a better try/catch.
  2. The flash of the wrong theme. useTheme reads the saved theme on render too. The server can't see localStorage, so it sends light mode and dark-mode users get a white flash. Store the theme in a cookie, or set data-theme from a tiny inline script in <head> before the page paints.
  3. Libraries that need a DOM. DOMPurify needs window. If the server must render article bodies (and for SEO it must), switch to a sanitizer that runs in Node, like isomorphic-dompurify or sanitize-html.
  4. Different output on each side. new Date().toLocaleString(), Math.random() ids and "5 minutes ago" labels all render differently on the server. Use useId() for ids, format dates in a fixed time zone, and fill in relative times in an effect after hydration.
  5. Browser-only APIs belong in effects or handlers. window.confirm in the article editor is fine, because it only runs on a click. The rule: render must be pure. Anything that needs the browser goes in useEffect or an event handler.
  6. Your API now gets called from two places. Server requests come from your server's IP, not the user's. CORS, rate limits and IP allow-lists may all need updating, and the server must forward the user's session explicitly.

None of these are about markup. They're all about which environment your code is running in.

Migrating SSR → CSR

The other direction is more common than people admit. A product starts as a Next.js site, grows into a dashboard that only logged-in users ever see, and the team is now paying for servers, cold starts and hydration bugs to render pages Google will never visit. Going back to a static SPA trades first-load speed for simple hosting and one runtime.

Both big frameworks let you do this without leaving them. Next.js has output: 'export', which builds a static site with no server. React Router framework mode has ssr: false, which builds a SPA from the same routes. That makes this a good first step: switch rendering off, keep the framework, and fix what breaks.

SSR (today) CSR (target) Keep in mind
loader / getServerSideProps / async server component useQuery in the component, or a client loader You now need loading and error states the server used to hide.
Session read from cookies() on the server Same cookie, but checked by your API on each call The browser can't read an HttpOnly cookie, and that's fine. Ask the API who the user is.
Middleware redirecting logged-out users A route guard like <RequireAuth> A guard is UX, not security. Your API must still reject the request.
Secrets and DB calls in server code A backend endpoint the SPA calls Anything left in a component ships to every visitor's browser.
Per-page <title> and meta from the server Edge meta injection, or prerender the public pages Skip this and every shared link shows the same preview.
Server Components with no client JS Regular components in the bundle The bundle grows. Split by route with lazy().

Gotchas in this direction

  1. Find every server-only import before you flip the switch. A component that imports your database client or reads process.env.API_SECRET will either fail the build or, worse, bundle the secret into public JavaScript. Grep for them and move them behind an API first.
  2. Every page needs a loading state now. On the server, a page simply waited for its data. In the browser, it renders first and waits after, so empty states, spinners and error messages you never needed suddenly appear.
  3. Deep links need a fallback. A static host must serve index.html for /article/hello-world, or every refresh outside the home page is a 404. Configure the SPA fallback on your CDN.
  4. Don't lose the public pages' SEO by accident. If the app still has a landing page, pricing or public profiles, keep those prerendered or keep a small SSR site for them. Only the logged-in part needs to become a SPA.
  5. Check your Core Web Vitals after the move. First load will get slower. Make sure it's still acceptable on a mid-range phone, then claw time back with code splitting and preloading the first data request.

So Which One Wins?

Neither. The deciding question is not the framework, it's the audience of each page. A stranger arriving from Google or a shared link judges you on the first second, and a crawler has to read the page. A logged-in user who keeps your app open all day cares about instant navigation, not first paint.

So the rule we follow in both directions: render on the server what strangers see first, render in the browser what members use all day. For most products that means a mix: SSR or static pages for the public side, a client-rendered app behind the login, and one framework that can do both.

Server-render what strangers see first. Client-render what members use all day.

Bottom line: whichever way you're migrating, take these habits with you:

  1. Render must be pure. No window, localStorage or random values while rendering. Browser work goes in effects and handlers. Code written this way can move in either direction.
  2. Auth lives in a cookie your API checks. It works for SSR, CSR and everything in between.
  3. Data fetching sits at the route, not deep in the tree. Route-level loaders or prefetches avoid waterfalls in both models.
  4. Decide per route, not per app. Mixed rendering is normal. Use a framework that lets each route choose.
  5. Measure before and after. LCP, INP and search impressions, on real devices. That's how you know the migration paid off.

Our Migration Plugins

Most legacy frontends we migrate are client-rendered, or are server-rendered pages with JavaScript bolted on top. Mapping every route, every bit of browser-only code and every data fetch before choosing a rendering model is slow work by hand. We've packaged how we do it as free, open-source plugins for Claude Code, one per starting point:

Plugin Starts from How it renders today
ember-to-react Ember Classic and Octane Client-side, with optional FastBoot SSR
angular-to-react Angular 2+ and AngularJS 1.x Client-side, with optional Angular SSR
backbone-to-react Backbone.js, Marionette, jQuery templates Client-side views, often on server-rendered pages
jquery-to-react jQuery apps, plugins and jQuery UI Server-rendered pages enhanced in the browser
vanillajs-to-react Plain HTML, CSS and JavaScript Static HTML plus scripts

Each plugin comes with a migration skill, a /<plugin>:plan command that writes a migration plan without touching your code, a read-only inventory agent that maps the old app, a parity-reviewer agent that compares each migrated piece with its original, and a bundled Context7 server for up-to-date library docs. To install one in Claude Code:

/plugin marketplace add cobuild-tech/cbx-plugins
/plugin install ember-to-react@cbx-plugins
Enter fullscreen mode Exit fullscreen mode

They also work in Cursor, in VS Code with GitHub Copilot, and in the GitHub Copilot CLI. Progress is kept in a .migration/ folder in your repo, so a migration can carry on across sessions and teammates. The source and setup steps are at cobuild-tech/cbx-plugins, and issues are welcome.

About MigrateX

MigrateX

MigrateX is our platform and service for moving software, data and infrastructure from one technology to another. It combines automation with experienced engineers. The automation does the repetitive work quickly, and the engineers make the tricky decisions, like which routes should render on the server and where the session should live.

A MigrateX project usually runs like this:

  1. Assessment. We map every route, data fetch and piece of browser-only code in your app, so you see where the work is before anyone agrees on a timeline.
  2. Plan. With your team, we decide which pages render where, what moves first, and what can simply be deleted.
  3. Migration, step by step. Automation handles the repetitive parts. Our engineers handle data loading, auth and the code that connects old and new.
  4. Proof that it works. The same tests run against both versions, and we compare load times on real devices. A piece only counts as moved when it passes on both.
  5. Handover. We remove the temporary code and leave your team with a codebase they understand and own.

Your app keeps running and shipping features the whole time, so there's no big launch day. The examples in this post come from our migratex-examples repo, which has each small app next to its migrated version.

SPA that needs SEO, or an SSR app that doesn't need its servers? Tell us a little about it through Contact Us, and we'll start with a free chat about where the work is likely to be.

Further reading

Source code


Originally published on the CobuildX blog.

Top comments (1)

Collapse
 
sinarezaei profile image
Sina Rezaei •

The part I find most interesting in a CSR ↔ SSR migration is that you're not really moving “rendering” from one place to another. You're moving ownership of the initial data snapshot too.

A case that gets tricky is a dashboard using TanStack Query with SSR prefetching:

request
  ↓
server fetch
  ↓
dehydrate(queryClient)
  ↓
HTML + dehydrated cache
  ↓
hydrateRoot()
  ↓
client query cache
Enter fullscreen mode Exit fullscreen mode

Now imagine the server renders an order as pending, but the order changes to paid before hydration finishes. If the client immediately refetches and replaces the hydrated snapshot, you can get a UI transition that looks like a hydration problem even though the markup itself was correct.

I've seen this become more interesting with dashboards where some queries are request-scoped while others are aggressively cached. You need to decide which data represents the server snapshot, which data is allowed to revalidate immediately, and which data should stay client-owned.

That's why I like the article's “decide per route” approach. I'd take it one step further and make the boundary per data dependency, not just per page. A mostly SSR route can still have a live client-owned widget without turning the whole route into CSR.

The rendering strategy then becomes less about “SSR vs CSR” and more about deciding who owns each piece of state, and for how long.