Thoughts on SSR and Its Use Cases
In the early days before front-end development matured and interactivity was not as demanding, page rendering was typically handled by template engines provided by backend languages (famous examples include ejs, pug, erb, thymeleaf, etc.). The HTML was rendered on the server side and sent to the browser, which then used JavaScript to handle various interactions.

While this sounds straightforward, several drawbacks emerged as browsers and the web evolved:
- Clicking to navigate to another page required sending a new request every time, meaning HTML, JavaScript, CSS, and the current preserved state all had to be reloaded and re-executed (assuming no cache).
- The flexibility of views was often constrained by the syntax provided by template engines.
- Sometimes, you want the view and the interactivity to be tightly coupled together; separating them can be inconvenient.
- As interactions became more abundant and nuanced, a reactive mechanism was often needed.
The evolution of SPAs allowed us to move all page interaction and presentation into JavaScript, making code easier to maintain through componentization and reactive mechanisms. However, relying solely on JavaScript cannot achieve certain things that traditional server-side rendering can, such as:
- Assisting SEO and search engine crawling
- Generating Open Graph content
- Optimizing the user experience
Next, I will explain each of these points. Before diving in, readers unfamiliar with the concepts of SPA and SSR can first refer to Huli’s article, 跟著小明一起搞懂技術名詞:MVC、SPA 與 SSR. Here, I will focus mainly on the benefits and practical use cases of SSR itself (using React as an example).
Assisting SEO and Search Engine Web Crawlers
When crawling a webpage, a crawler processes the page’s HTML content to index it, and caches a copy in its database that is updated periodically. In other words, if SSR is not implemented, the HTML file itself is completely blank, and the actual page cannot be seen until main.js is parsed and executed.
For example:
<html>
<head>
</head>
<body>
<div id="app"></div>
<script src="main.js"></script>
</body>
</html>
Although Google claims it can parse and execute JavaScript, I think its capabilities are still limited, and it cannot handle asynchronous requests like fetch reliably.
Taking 17LIVE as an example, when searching for 17LIVE on Google:

In the search results, search engines usually display the <title> and <meta name="description" content="xxx"/>. These can be generated directly by the server or hardcoded into the HTML file. However, if you click on the cached version, you’ll find that the result is completely blank:

Inspecting the source code further reveals an HTML file with an empty body:
<!DOCTYPE html>
<html>
<head>
<title>17LIVE - Live Streaming 直播互動娛樂平台</title>
<meta charset="utf-8">
<meta name="description" content="17LIVE 直播互動零距離。各式特色才藝直播主分享生活每一刻;多元節目內容免費線上看!" />
...
</head>
<body></body>
</html>
Generating Open Graph Content / Managing <head>
On platforms like Facebook, Twitter, and LINE, when you post a link in a feed or chat, crawlers send a request to the URL and parse the <meta> tags inside to determine how to display the link preview (for details, refer to Open Graph Protocol). This data must be returned directly by the server; otherwise, the crawler will only see blank content.
In React, managing content placed in <head> is typically achieved in a few ways:
- react-helmet: Allows you to manage
<head>content via components, with support for server-side rendering. - next/head: Next.js also has a built-in way to manage
<head>.
Optimizing User Experience
When parsing a webpage, the browser always receives the HTML first and then parses the JavaScript; the content cannot be seen until the JavaScript finishes executing. Although the difference may not feel significant on mainstream PCs (with an i5 CPU or better), here are a few considerations:
- Users don’t necessarily browse using PCs or smartphones: they might also browse via IoT devices, e-readers (like Kindle), PS4, smart TVs, etc. These devices often cannot execute JavaScript efficiently, and their performance is limited.
- Not every user has JavaScript enabled (though this is a minority). If a user sees a blank page, they might leave immediately, causing you to lose potential users.
- With the help of SSR, the framework can match the pre-rendered content when JavaScript executes, saving performance overhead by avoiding DOM API calls like
document.appendChild.
Differences from Traditional Template Engines

Doing SSR with modern frontend frameworks like React or Vue differs from traditional template engines in several key ways:
- Traditional template engines render pure static HTML strings with variables injected by the server. With frontend frameworks, in addition to rendering HTML strings on the server, the client side dynamically calls DOM APIs on the HTML, attaching corresponding event listeners (
click,change, etc.) and executing relevant lifecycle methods after SSR rendering. - Traditional template engines have no concept of reactivity; they do not automatically update the DOM when variables change.
- Because the rendered HTML must match the frontend code, frontend and backend code must be shared (when rendering HTML). Therefore, SSR with frontend frameworks typically requires Node.js. Traditional template engines, on the other hand, depend on whichever backend programming language you choose.
How Do Frontend Frameworks Achieve SSR?
I won’t delve into the implementation details of any specific framework here. Instead, I’ll explain how mainstream frontend frameworks achieve SSR in general.
For a frontend framework to achieve SSR, the first thing to consider is state. Under the assumption that a given state always renders the same UI, if we want the frontend and backend to render the identical view (initial view), we must ensure that the frontend and backend share the exact same state (when rendering the HTML).
Generally, we prepare a global store during server-side rendering:
route.get('/', (req, res) => {
const store = {
posts: [],
user: {
name: 'kalan',
...
},
};
const html = ReactDOMServer.renderToString(<App />);
res.render('index', {
html: html,
store: JSON.stringify(store);
})
});
Then, we place the store data into a global variable:
<div id="app">
<%- html %>
</div>
<script>
window.GLOBAL_STORE = store;
</script>
Finally, in app.js, we write:
ReactDOM.hydrate(<App store={window.GLOBAL_STORE} />, document.getElementById('app'));
Here, the hydrate API is used to tell React that the content has already been pre-rendered on the server. React skips creating the DOM nodes from scratch, instead attaching event listeners and running lifecycle hooks, useEffect, and so on. Note that SSR only refers to this initial render (i.e., the HTML received upon the first request).
As the app grows in complexity, managing a global variable manually is no longer a great idea. At this point, you can consider frameworks like next.js to help simplify the considerations and implementation complexity of SSR.
Common Pitfalls: Dynamic Import and Ajax
As an app grows, because an SPA bundles all view and interaction logic on the frontend, the bundle size easily reaches a critical point, negatively impacting initial load performance. In this scenario, dynamic imports can be used to split less critical components or pages into separate files, requesting them only when needed.
Currently, React.lazy provided by React does not support SSR. You can refer to the official SSR guide (using loadable-components).
The approach of loadable-components is to collect all dynamic import points in the current <App/> component to generate a manifest file, and then load these files via <script src="xxx.js">. If all components on a page are rendered dynamically this way, the initial render may still end up completely blank (because the files still need to be fetched via <script> tags).
Additionally, if data is fetched on the frontend via ajax without fetching it on the server first, the initial render will also be blank or show a loading state. Suppose we have a simple component like this:
const App = () => {
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/data').then(res => res.json())
.then(data => setData(data));
}, [])
if (data.length === 0) {
return <span>loading</span>
}
return <div>
{renderData()}
</div>
}
When React performs SSR, it does not execute the code inside useEffect. It simply injects server data into the component and renders the markup; actual network requests are only sent after the browser renders the page. Therefore, the rendered output will look like this:
<span data-reactroot="">loading</span>
For SSR, such a page might slightly reduce the initial rendering load, but it is poor for both SEO and user experience. After all, the main goals of SSR are better user experience (no waiting for loading spinners) and better SEO (crawlers wouldn’t just see a loading screen). A better approach is to send server data to the client via a global store or leverage Next.js mechanisms like getStaticProps().
Does Having SSR Really Make a Big Difference?
This depends on the angle you evaluate SSR from and the specific use case. Taking the 17LIVE example mentioned earlier, the pages consist mostly of dynamic video streams. Converting it to SSR provides minimal benefit, and the migration cost is relatively high—both of which are key considerations.
Similarly, when developing internal admin dashboards, users are typically internal employees, meaning SEO is not a concern, and it’s reasonable to assume their devices perform well. Thus, skipping SSR is completely fine. In reality, implementing SSR introduces a lot of factors to consider. If SSR wasn’t planned from the start, the migration cost increases significantly over time. It’s best to evaluate the need for SSR early in the development lifecycle and prepare as early as possible. For applications with high SEO demands—such as blogs, e-commerce, content portals, and landing pages—SSR should be a crucial consideration.
However, rather than debating the necessity of SSR in the abstract, I believe it is better to approach the discussion directly from the perspective of user needs. After all, the primary purpose of any technology and product is to serve people.
For example:
- Users might view our website via e-readers, IoT devices, or lower-end hardware, making it essential to minimize unnecessary performance overhead.
- Users use the service to accomplish a specific goal, so what needs optimization might be the flow or the UI/UX design, using the browser and JavaScript to enhance the experience where possible.
Alternative Solutions and Considerations
Rather than looking strictly at “best practices” here, let’s explore practical applications under real-world constraints:
- If introducing SSR directly into your current setup isn’t cost-effective, you might have the backend serve a simplified view specifically for crawlers, while relying on client-side JavaScript rendering for actual users. While this requires maintaining two different views, it can sometimes be the simpler route.
- If the content is identical for every request, you can simply pre-render pure static HTML.
- If migration costs are too high, you can use a headless Chrome like puppeteer to visit the pages, cache the rendered HTML on the server, and update it periodically via cron jobs.
- Choosing an SPA inevitably leads to increased frontend JavaScript complexity and larger bundle sizes. Sometimes, opting for traditional server-side rendering combined with
prefetchmechanisms might actually yield better performance and user experience.
Related Posts
- Recreating My Room with Three.js Using React Three Fiber, I brought my real room into the browser—turning physical objects into an interactive table of contents, and using spatial memory to tell the story of my life and work over the past few years.
- Things to Keep in Mind When Using Images in Frontend Development Expanding on Jake Archibald's article, this post organizes how modern responsive images should be written: why width/height are still necessary, when to use CSS aspect-ratio, how to choose between AVIF and WebP, and using picture/source/srcset for art direction on mobile devices.
- CSS field-sizing — Auto-resize Form Elements with a Single Line of CSS Previously, auto-resizing a textarea required listening to scrollHeight in JavaScript. With CSS field-sizing: content, a single line replaces it all, supporting textarea, input, and select. This article covers the pain points of older approaches and how to use field-sizing.
- Make Your Link Underlines Look Better: text-underline-offset By default, underlines sit very close to the text. Some designers dislike this look, and personally, I don't think it looks great either.