
Frontend rendering modes: CSR, SSR, and SPA
CSR
Mainstream frontend frameworks in China, such as Vue and React, generally use CSR (Client-Side Rendering). When a user visits a page, the browser first receives an almost empty HTML file plus the related JavaScript:
<html>
<head>
<title>title</title>
</head>
<body>
<div id="root"></div>
<script src="./index.js"></script>
</body>
</html>
The framework then renders content into a specific node. Think of it as document.getElementById("root").innerHTML = "...".
In CSR, clicking in-page navigation usually does not request a whole new page. React recommends <Link> instead of <a>; React Router (history / hash under the hood) re-runs JavaScript to navigate and render. This is also called a single-page application (SPA).
CSR advantages:
- After initialization, in-app navigation is fast and can update parts of the page without hitting the server every time.
- Data can be fetched and rendered dynamically with AJAX and similar tools.
CSR disadvantages:
- It depends heavily on JavaScript and must wait for it to download and execute.
- First-screen loading is slow because the initial HTML is empty, which can flash a blank page.
- SEO is weak; crawlers may not see the real content.
SSR
Taking Next.js as the usual example, SSR (Server-Side Rendering) pre-renders HTML on the server and returns it to the client.
The first HTML usually has no interaction yet (no click handlers on DOM nodes). Script tags are still there. After the client loads them, hydration attaches data and event logic; only then is the page fully interactive. From that point, further rendering and routing belong to the client. Mainstream SSR frameworks (Next.js on React, Nuxt.js on Vue) sit on top of traditional CSR frameworks.
CSR returns simple HTML, then downloads JavaScript to mutate the DOM. SSR returns complete HTML up front, so the first screen is better. Next and Nuxt are the common choices.
Why hydration?
Why not finish the JavaScript logic while rendering on the server? React and Vue components do not really serialize their state. They depend on a runtime, especially closures and event handlers.
Hydration is the client taking over the static HTML from SSR and making it interactive: after scripts load, they “activate” the markup (event listeners and so on). In an isomorphic / universal app, frontend and backend share one rendering path. The server emits the first HTML for first paint and SEO; the client then fills in events and state.
The client and server DOMs must match, or hydration errors follow. React also has APIs such as
suppressHydrationWarningto skip the warning.
function Counter() {
const [count, setCount] = useState(0);
// increment captures state from the outer scope
const increment = () => {
setCount(count + 1);
};
return <button onClick={increment}>{count}</button>;
}
The hard part is knowing which handlers to attach, which DOM nodes to attach them to, and how to restore related state.
- what: handlers often close over component state, so JavaScript must run again to restore APP_STATE.
- where: each handler must bind to the right node and event type.
Framework internal state (FRAMEWORK_STATE) also has to come back: which components should re-render, which data to sync. In short, the client uses JavaScript to restore application and framework state so the page can interact again.
SSR advantages:
- Less dependent on JavaScript; content can still show with JS disabled.
- Faster first screen; no wait for client JS to download and run.
- Better SEO; the server sends complete HTML.
- Caching can reduce work on the client.
SSR disadvantages:
- Needs a server; you cannot dump the whole site on a CDN like a static page.
- Server concurrency and performance need real capacity planning.
- TTI may get longer because the page waits for JS download and hydration.
Rolling your own SSR: isomorphism in practice
One older project did not have Next.js yet, so it implemented its own SSR framework.
References:
react-dom/server’s renderToString turns JSX into a string:
// server/index.js
import express from "express";
import { renderToString } from "react-dom/server";
import Home from "./containers/Home";
const app = express();
const content = renderToString(<Home />);
app.get("/", function (req, res) {
res.send(
`
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
</body>
</html>
`
);
});
app.listen(3001, () => {
// console.log('listen:3001');
});
Isomorphism, simply: the same code runs on client and server and should produce the same result. If JSX has click handlers, renderToString output has none of those bindings, so you still need a script tag. React’s client API for taking over is hydrateRoot.
A common failure is a hydration error: client and server DOMs disagree, and mounting breaks (this is tied to React’s virtual DOM). Adding suppressHydrationWarning on a div only hides the warning.
Isomorphism also brings these concrete problems:
Routing. Client and server must share the same routing logic. React apps usually use React Router; in SSR use StaticRouter, not BrowserRouter.
Redux. The store has two connections: one on the client, one on the server. Both pass the store through react-redux Provider. The server store needs extra handling, or every export becomes the same store instance.
State injection. Initialize the store and put state on window.
You also still have to deal with async data hydration/dehydration, nested-route rendering, a Node middle layer, CSS on the server, and SEO (react-helmet).
SSG
SSG (Static Site Generation) extends SSR: at build time every page becomes static HTML. There is no server computation at runtime and no heavy client JS. The files can sit on a CDN, which is much cheaper than SSR.
Typical for docs, blogs, marketing sites, and product pages with little interaction.
ISR
ISR (Incremental Static Regeneration) sits between SSR and SSG: generate static pages ahead of time, then refresh some of them on demand without rebuilding the whole site.
In Next.js, revalidate sets how soon a page may regenerate in the background. After that window, the next visit fetches fresh data, rebuilds the page, and replaces the old static file. A CMS update can also trigger on-demand regeneration.
// pages/blog/[slug].tsx
import { GetStaticProps, GetStaticPaths } from "next";
interface Post {
slug: string;
title: string;
content: string;
}
export default function BlogPost({ post }: { post: Post }) {
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
// Generate static paths
export const getStaticPaths: GetStaticPaths = async () => {
// Get all posts from the API/database
const posts = await fetch("https://api.example.com/posts").then((r) =>
r.json()
);
const paths = posts.map((post: Post) => ({
params: { slug: post.slug },
}));
return {
paths,
fallback: "false",
};
};
// Generate a static page for each path
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await fetch(
`https://api.example.com/posts/${params?.slug}`
).then((r) => r.json());
return {
props: { post },
// Enable ISR: 60 minutes
revalidate: 60 * 60,
};
};
Next
Next.js is Vercel’s open-source React framework, focused on SSR and SSG.
Main features:
- SSR, SSG, and ISR out of the box for first paint and SEO, with background updates.
- File-system routing, no manual route table.
- Built-in API routes, so frontend and backend can live together.
- Native CSS / Sass / CSS-in-JS plus static asset and image optimization.
- Automatic per-route splitting and lazy loading.
- A large plugin ecosystem for data fetching and state.
- One-click Vercel deploy, plus Node and serverless targets.
Next.js has page route and app route. A typical page-route SSR page looks like this:
function Home({ serverData }: { serverData: string }) {
return (
<div>
<h1>Server-rendered page</h1>
<p>Data from the server: {serverData}</p>
</div>
);
}
export async function getServerSideProps() {
// Fetch data on the server
// const data = await fetch(...)
const serverData = "Hello, SSR!";
// The props in the returned object are passed to the page component
return {
props: { serverData },
};
}
export default Home;
getServerSidePropsruns on every request, passespropsinto the page, and that is Next’s SSR.
Nuxt.js is the Vue-ecosystem counterpart of Next.js.
Qwik
Qwik’s core idea is skipping hydration: serialize JavaScript logic and state into HTML on the server. Resumable means download and run JS on demand.
<!-- The core of Qwik's serialization into HTML -->
<div q:host>
<div q:host>
<!-- Event-handler reference: points to a specific JS file and function -->
<button on:click="./component_onClick.js#handler">Add</button>
</div>
<div q:host>
<!-- q:obj stores a reference to component state -->
<button q:obj="1" on:click="./component_onClick.js#handler[0]">10</button>
</div>
</div>
<script id="qwikloader">
/* Code that sets up global event listeners in Qwik */
</script>
<script type="qwik/json">
/* Event-listener management and state-deserialization data */
</script>
The first click downloads the handler; later clicks run the already loaded function. TTI is excellent, but the ecosystem is thinner than Next.js, so it is rarely the default unless performance is the top requirement.
There are also Islands-style approaches, such as Astro. That is out of scope here.
Summary
- SSG & ISR: fastest load (static HTML), best SEO, lowest cost; updates need a rebuild. ISR can refresh incrementally and suits large content sites.
- SSR: fast first screen, flexible dynamic content, SEO-friendly; needs a server and costs more. If you roll your own, isomorphism, hydration, routing, and the store are the main pitfalls.
- CSR: best interactivity and later navigation; slow first screen, weaker SEO, and a lot of JavaScript.
