Server-side React
SeattleJS
Server-side rendering a React application can sound bigger than it is. It has a lot of moving pieces, and those pieces matter, but the point is direct: use the server to send something useful before the browser has finished downloading, parsing, and executing the application.
The typical client-rendered React application starts with almost nothing. The server sends an empty <div>, a bundle, and a prayer. The user waits through a blank screen, then a loading state, then eventually an application. That is not just a performance concern. It is a trust concern. When someone follows a link and the screen stalls out before giving them context, the interface is asking for patience before it has earned any.
Server-side rendering changes that first impression. The server renders the initial HTML. The browser receives real content. React then hydrates that markup and takes over interactivity on the client. One side handles the first useful paint; the other keeps the application alive after it arrives.
Why Render on the Server?
Single page applications historically used the browser as the runtime for nearly everything. The HTML document was mostly a delivery mechanism for JavaScript. That can work beautifully under ideal conditions, but the ideal case is not the case we should design around.
The old concerns are still relevant:
- A single page application has a single point of failure.
- It can be weak at exposing meaningful content to crawlers and link previews.
- It can make users wait for the entire JavaScript pipeline before they see the thing they came for.
Server rendering is not a rejection of rich client applications. It is the admission that the server and client are good at different moments in the same experience. The server is good at sending a useful first answer. The client is good at continuing from there.
This is why the phrase “isomorphic JavaScript” was useful for a while, even if it became a little awkward. The better point is not that the same code must run everywhere. The better point is that the user should not have to know where the work happened.
The Smallest Useful Version
In a basic setup, the server calls renderToString from react-dom/server, wraps the result in an HTML document, and sends it to the browser.
import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import App from "./components/App";
const app = express();
app.get("/*", (request, response) => {
response.send(template());
});
app.listen(3000);
function template(props) {
const app = renderToString(<App {...props} />);
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>React SSR</title>
</head>
<body>
<div id="root">${app}</div>
<script src="./client.js"></script>
</body>
</html>
`;
}
The client then changes from render to hydrate.
import React from "react";
import ReactDOM from "react-dom";
import App from "./components/App";
ReactDOM.hydrate(<App />, document.getElementById("root"));
Hydration is the handoff. React sees markup that already exists and attaches behavior to it. The page is not born empty. It arrives with shape, then becomes interactive.
That is the heart of the pattern. The rest of the talk is the fine print.
The Fine Print
React components do not have the same life on the server that they do in the browser. The initial render is constrained:
- You get a truncated lifecycle:
constructor(),UNSAFE_componentWillMount(), andrender(). - You do not get meaningful
setState()during that render. - You do not get browser globals like
window,matchMedia,localStorage, or the browser’s nativefetch.
This is the part that tends to surprise people. The server is not a fake browser. It is a different runtime. If the component needs to know about viewport width, persisted browser state, or a client-only API, that dependency has to be isolated, mocked, deferred, or modeled another way.
There is also a build concern: JSX is not valid JavaScript. If you call components from the server, that server code still has to be transformed. In practice, you need a client bundle and a server bundle, and you need to be intentional about what goes into each.
Routing
Most React applications need routing, and most routed React applications use the browser URL to decide what to render. On the client, React Router uses the History API and window.location. On the server, those browser objects are gone, but the request still has a URL.
The pattern is to use BrowserRouter on the client and StaticRouter on the server.
ReactDOM.hydrate(
<BrowserRouter>
<App />
</BrowserRouter>,
);
renderToString(
<StaticRouter location={request.url} context={{}}>
<App />
</StaticRouter>,
);
That empty context object looks strange the first time you see it. React Router uses it as a communication channel during the server render, for example when a route wants to redirect. The important part is that the URL becomes explicit input. The server can render the route the user requested instead of sending a generic shell and asking the client to figure it out later.
Data Fetching
Data fetching is where server rendering becomes useful rather than merely clever.
In a client-only application, a common flow looks like this:
- Render an empty or loading state.
- Mount the component.
- Fetch data.
- Put the data in state.
- Render the useful UI.
That is understandable, but it means the first render is often the least useful one.
For server rendering, move the data fetching outside the component lifecycle. Fetch the data before rendering, pass it into the component as props, and let the first HTML response contain the state the user actually came to see.
async function handler(request, response) {
const repos = await API.getRepos();
response.send(template({ repos }));
}
The component can still be ordinary React:
class Container extends React.Component {
render() {
return <RepoList repos={this.props.repos} />;
}
}
The next problem is duplicate fetching. If the server fetched the data and the client immediately fetches the same data again, the application is doing extra work and maybe flashing through avoidable states. The usual fix is to serialize initial data into the page, read it on the client, and delete it when it has been consumed.
Conceptually:
- Put the server data on
window.__INITIAL_DATA__. - Let the client check for it before fetching.
- Remove it once the application has taken ownership.
That sounds clumsy because it is a little clumsy. It is also the shape of the problem: the server and client have to agree about the handoff.
Authentication
Authentication is a good example of why the server is valuable. If the user has a token in a cookie, the server can read it from the request and make the same authenticated API call the client would have made later.
async function handler(request, response) {
const { TOKEN } = request.cookies;
const data = await fetch("endpoint/", {
headers: { Authorization: TOKEN },
}).then((res) => res.json());
response.send(template({ data }));
}
This does not make authentication simple, but it puts the first render in a better position. The initial page can be personalized and useful without waiting for the browser to rediscover what the server already knew from the request.
Hooks and Suspense
Hooks were new when this talk was written, but the principle still holds: on the server, hooks render their initial values. A hook does not make the server into a browser. It gives you a cleaner way to express component state and shared logic.
const ThemeContext = React.createContext({ dark: true });
function App(props) {
const [open, setOpen] = React.useState(false);
const context = React.useContext(ThemeContext);
return (
<div
className="App"
style={{
background: context.dark ? "black" : "white",
color: context.dark ? "white" : "black",
}}
>
<h1>Hello {props.name}</h1>
{open && "I AM OPEN"}
<button onClick={() => setOpen(!open)}>Toggle</button>
</div>
);
}
The default theme is available. The default open state is available. The click handler matters later, after hydration.
Suspense was also still emerging when this talk happened. The hope was that it would make data dependencies easier to express and easier for server rendering to understand. The larger lesson is more durable than the API status: the closer data requirements are to the UI that needs them, the more the rendering system needs a serious answer for orchestration.
Do Not Travel Alone
It is worth understanding server-side rendering by hand. It teaches you where the constraints are: routing, data fetching, bundling, hydration, authentication, and runtime boundaries.
It is usually not worth owning all of that machinery indefinitely.
Use a framework when the rough edges become the work. Next.js stood out because it handled many of these concerns in a way that let teams focus on product instead of plumbing. Razzle, After.js, and universal component approaches also existed to absorb pieces of the problem.
The point is not that server-side rendering is magic. It is that the first response matters. If the user came for content, send content. If the application needs JavaScript, hydrate it. One hand washes the other.