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:
- The browser asks for
/article/hello-worldand gets backindex.htmlwith an empty<div id="root">. - It downloads and runs the JavaScript bundle.
- React starts, the router reads the URL, and the page asks the API for the article.
- 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 />
);
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,localStorageanddocumentare 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__} />);
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,documentorlocalStoragewhile 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.
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:
- 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.
- 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.
- Move data fetching into loaders, route by route. Start with the public, SEO-critical pages: article, profile, home feed.
- Move auth into a cookie. Until the server knows who the user is, every logged-in page can only render as "logged out".
- Add per-page titles and meta tags. That's half the reason you came.
- 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} />;
}
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.
Gotchas we hit
A client-rendered app quietly assumes it's in a browser everywhere. Each of these lines was fine yesterday:
-
localStorageduring render.SessionContextreads the token in auseStateinitializer, which also runs on the server. Thetry/catcharound 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 bettertry/catch. -
The flash of the wrong theme.
useThemereads the saved theme on render too. The server can't seelocalStorage, so it sends light mode and dark-mode users get a white flash. Store the theme in a cookie, or setdata-themefrom a tiny inline script in<head>before the page paints. -
Libraries that need a DOM.
DOMPurifyneedswindow. If the server must render article bodies (and for SEO it must), switch to a sanitizer that runs in Node, likeisomorphic-dompurifyorsanitize-html. -
Different output on each side.
new Date().toLocaleString(),Math.random()ids and "5 minutes ago" labels all render differently on the server. UseuseId()for ids, format dates in a fixed time zone, and fill in relative times in an effect after hydration. -
Browser-only APIs belong in effects or handlers.
window.confirmin the article editor is fine, because it only runs on a click. The rule: render must be pure. Anything that needs the browser goes inuseEffector an event handler. - 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
-
Find every server-only import before you flip the switch. A component that imports your database client or reads
process.env.API_SECRETwill either fail the build or, worse, bundle the secret into public JavaScript. Grep for them and move them behind an API first. - 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.
-
Deep links need a fallback. A static host must serve
index.htmlfor/article/hello-world, or every refresh outside the home page is a 404. Configure the SPA fallback on your CDN. - 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.
- 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:
-
Render must be pure. No
window,localStorageor random values while rendering. Browser work goes in effects and handlers. Code written this way can move in either direction. - Auth lives in a cookie your API checks. It works for SSR, CSR and everything in between.
- Data fetching sits at the route, not deep in the tree. Route-level loaders or prefetches avoid waterfalls in both models.
- Decide per route, not per app. Mixed rendering is normal. Use a framework that lets each route choose.
- 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
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 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:
- 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.
- Plan. With your team, we decide which pages render where, what moves first, and what can simply be deleted.
- Migration, step by step. Automation handles the repetitive parts. Our engineers handle data loading, auth and the code that connects old and new.
- 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.
- 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
- web.dev. Rendering on the Web. https://web.dev/articles/rendering-on-the-web
- React. hydrateRoot. https://react.dev/reference/react-dom/client/hydrateRoot
- React Router. Rendering Strategies. https://reactrouter.com/start/framework/rendering
- Next.js. Static Exports. https://nextjs.org/docs/app/guides/static-exports
- TanStack Query. Server Rendering & Hydration. https://tanstack.com/query/latest/docs/framework/react/guides/ssr
- Cloudflare. Pages Functions middleware. https://developers.cloudflare.com/pages/functions/middleware/
Source code
- CobuildX AI. cbx-plugins: Claude Code plugins for Backbone, Angular, Ember, jQuery and Vanilla JS to React migrations. https://github.com/cobuild-tech/cbx-plugins
- CobuildX AI. migratex-examples: small jQuery, Backbone, Angular, Ember and Vanilla JS apps and their migrated versions. https://github.com/cobuild-tech/migratex-examples
Originally published on the CobuildX blog.



Top comments (1)
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:
Now imagine the server renders an order as
pending, but the order changes topaidbefore 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.