Server-side rendering is extra work
- Publication date
- May 13th, 2025
React code is meant to run in a web browser. Running root.render(<App />)
in a browser will display whatever content App
renders on the screen. After that, the app is meant to "react" to changes that happen in the browser, usually due to user interaction events like clicking and typing. Some aspect of state will get updated via a "hook" such as useState
, the appropriate components will "react" to those changes by re-rendering their content, and React will figure out what to update on the screen to match.
But often, a lot of this code renders content that never changes. This blog website is a great example! In those cases, we can help speed up the initial rendering of the page by "server-side rendering" our app.
With a normal "client-side rendered" React app, the communication between the client/server goes like this:
- Browser asks for whatever's at a given URL
- Server sends an HTML document which is little more than this:
123456789<html> <head> <title>My Blog</title> </head> <body> <div id="root"></div> <script src="main.js"></script> </body> </html>
- Browser renders this blank screen, sees we need
main.js
and asks the server for it - Server sends
main.js
- Browser runs
main.js
, which includes the React code to render content inside the<div id="root"></div>
But what if that React code is just this?
123456789function BlogPost() { return ( <p> React code is meant to run in a web browser. </p> ); } createRoot(document.getElementById('root')).render(<BlogPost />);
That content will never change! There's nothing to react to! We should have just included this text with the HTML the server already sent.
That's essentially what SSR does; it "pre-renders" React code on the server as an HTML string and sends it to the browser as a streaming Response
, such as:
12345678910111213141516import { Hono } from 'hono'; import { renderToReadableStream } from 'react-dom/server'; import { BlogPost } from './BlogPost'; const app = new Hono(); app.get('/', async () => { const stream = await renderToReadableStream(<BlogPost />, { bootstrapScripts: ['/main.js'] }); return new Response(stream, { headers: { 'content-type': 'text/html' }, }); }); export default app;
12345import { jsx } from 'react/jsx-runtime'; import { hydrateRoot } from 'react-dom/client'; import { BlogPost } from './BlogPost'; hydrateRoot(document.getElementById('root'), jsx(BlogPost));
12345678910111213141516export function BlogPost() { return ( <html> <head> <title>My Blog</title> </head> <body> <div id="root"> <p> React code is meant to run in a web browser. </p> </div> </body> </html> ); }
But the browser can't just render that HTML on the screen and call it a day. This React app is supposed to react to things, after all. This HTML tells us nothing about what this "app" is supposed to do.
It's a bit like asking someone to teach you to cook lasagna, and all they did was give you a picture of a cooked lasagna. You might have a few ideas on how to do it, but you really need the recipe. That main.js
"bootstrap" script is meant to be the "recipe" for how to recreate, or "hydrate" the app on the frontend. React does this by re-rendering the entire app again in the browser, like we normally would have, so that it can know which React elements were used to create what HTML DOM nodes, attach event listeners where we need them to make the content interactive, and to know what React hooks like useState
s could cause what components to re-render or what useEffect
s should run when those components render. Even with "server rendering," we still need our component code to be able to run in the client.
If that sounds like a lot more complexity and effort to essentially just get a (potentially) faster initial "paint" on the screen, you'd be right! There are other cool things that SSR can let you do like "suspend" component rendering while you load data on the server, encode server data as component props, and "resolve" the resulting rendered content in the streamed HTML when it becomes available, but that's not the focus of this post.
The point is that SSR is an optimization you can add on top of traditional client-rendered React, but it comes with complexity costs, and we still need to make sure our components can be rendered in both client & server environments. With SSR, React can pre-render the initial state of the app on the server and send that as plain text, but we can't get away from needing to re-render the app in the browser because the browser doesn't know what underlying state or lexical context was used to create that initial state. It has to redo the rendering work so that it can re-constitute the "train of thought" that the server had when it rendered the app, so that it can take the ball and run with it.
When you think about it, there's a middle man we can take out here. The server is rendering components to HTML that it's expecting the browser to blindly adopt, but in a React app, it's supposed to be the browser's job to both render components and decide what HTML to paint on the screen.
The essence of what SSR aims to do is tell the React that's running in the browser which parts of the UI it should ultimately be responsible for and which parts it shouldn't have to worry about and should just adopt and use. It would be great if the React on the server could just talk to the React in the browser in the language it understands for describing UI, which is "React elements." If it could do that, then it could explicitly tell the browser things like "I rendered this content for you, you can safely use it as-is, because it doesn't need to react to anything and change." Or, "Here's some content, but it could change, so you will need to take over from here."
If only there was a way to have true "server components" like the server-side templating frameworks of yore, where I could ensure the component code would only ever run on the server, and I could do something like this:
1234567import db from './my-server-db'; export async function MyData() { const data = await db.get('my-data'); return <div>My data is: {data}</div>; }
Well, that is exactly what Server Components do! More on that next time...