I’ve been building UI components with React for a while now, and I’ve read a lot about server-side rendering (SSR). From what I’ve read, I didn’t expect SSR to affect how React components are built.
Lately, I’ve been working on a component library, and one of the consumer teams I worked with uses SSR. After building many components, I received feedback that several of them weren’t working correctly when SSR was used. This got me thinking about why this was happening and how SSR might affect building React components.
So, why would you need SSR in the first place?
Why you might need SSR
Server-side rendering (SSR) has been the default way to render web apps since the first website was ever built. However, the term is typically used when talking about single-page applications (SPAs), as it promised an out-of-the-box solution for all the problems that came with SPAs while maintaining the same advantages — and this has been mostly true.
- Since servers are controlled by application vendors, by providing the right resources, performance can be ensured even for users with low-end devices.
- Since the browser gets the HTML ready for rendering, rendering should be faster than SPAs.
- Since it allows users with slower devices to use your app, SSR also increases accessibility.
- Since search engine crawlers have a hard time crawling an empty SPA HTML page, providing the full HTML makes SSR makes websites more search engine friendly.
- This also affects sharing pages to social media platforms as SSR provides the page with the meta-tags needed to render the preview cards.
How does SSR work with React?
On the server side:
When a request for the HTML page hits the server, ReactDOMServer is called to render the app to a static HTML string:
const app = ReactDOMServer.renderToString(<App />);
What happens under the hood is that starting from this App Root node, ReactDOMServer runs the entire app in a NodeJS Environment synchronously and returns whatever HTML the render function returns.
The server then returns the rendered HTML to the static HTML page.
return `
<html>
<head></head>
<body>
<div id="root">${app}</div>
</body>
<script src="./app.js"/>
</html>
`
On the client side:
After the browser receives the HTML page, it renders the HTML into the page and then calls the app script.
The app.js script uses ReactDOM to hydrate your static page to attach event listeners and start all the async operations.
const root = document.querySelector("#root");
ReactDOM.hydrate(<App />, root);
How is server-side rendering different from client-side rendering?
When building applications that render on the client side, you can rely on the browser to make web APIs & DOM available for your app to consume. You can use the document, window, or web APIs to provide information about the browser or ask it to do certain calculations for you. You can then use it to decide what to render and what not to render. You can expect the Event Loop to call async functions and run event listeners for you to be able to fetch data or re-render components.
SSR doesn’t do any of those. It almost doesn’t know anything about the client side and only returns the HTML from the app’s first synchronous render.
Dynamic layout calculations
Imagine a breadcrumbs component that shouldn’t be multi-lined, and you have to remove elements from the center until you have some content that fits only in one line. Essentially, you want to get from something like this:
to this:
Since every element has a dynamic width, what you have to do here is decide what to render and what not to based on the actual element's dimensions.
Typically, you’d achieve this by doing a first render where you gather all the elements’ dimensions and manipulate the state to render the shorter list of elements.
React provides two Hooks to help with this, useEffect and useLayoutEffect. Both do the same thing, but useLayoutEffect is meant to be used for layout changes. Your code would be something like this:
import React, { useLayoutEffect, useState, useRef } from 'react';
const Component = ({ list }) => {
const [items, setItems] = useState(list);
const containerRef = useRef(null);
useLayoutEffect(() => {
// use containerRef to calculate newItemsToRender
const newItemsToRender = [];
setItems(newItemsToRender);
}, [list]);
return (
<div ref={containerRef}>
{items.map((item) => (
<ItemComponent item={item} />
))}
</div>
);
};
No web APIs/DOM
On the server side, there’s no actual DOM manipulation. The component returns an HTML string that won’t be rendered until it reaches the client side. And you don’t even have access to DOM, which means you won’t be able to access Refs, where you can get elements dimensions. If you try to render the component on the server side, not only will you get a warning from React stating that useLayoutEffect does nothing on the server side, but rather, your page will return the full list of items. The calculations won’t happen until the client hydrates your JS app. This would result in a visual change that users would notice, and the whole page would jump when the list goes from multi-lined to one line.
How to go around it?
There are several workarounds that should make your app look normal while the client hydrates your app.
- Delay rendering of the actual list until the app is hydrated and render a skeleton or a loading state instead, something like this:
- Use the user-agent request header to get data about the client side, then render a safe number of items. User-agent should give you sufficient information about the device and browser type, libraries like should help parse the UA string, but that's more helpful for mobile devices. You can predict the average dimensions of the browser based on the device type.
- Fix the string length by trimming the long string and use CSS media queries and breakpoints instead. CSS is safer to use on both the server side and client side as the browser would apply CSS shipped with the page on the first render.
But can't you use tools like JSDOM?
JSDOM is a pure-JavaScript implementation of many web standards. It’s one of the tools that can provide you with a client-like environment, but there are two main differences from client-side rendering.
- These tools are more complicated to set up, are slower, and are resource-hungry.
- Even though JSDOM provides an environment that looks like a client, it’s still not the client used by the end user, which would make things even trickier. You still know very little about the actual client, like browser size, device type, etc. As a result, you can’t rely on the layout APIs since they might give you misleading data.
Are these the only differences between client-side rendering and server-side rendering?
There are many other differences that don’t really have anything to do with Building UI components, but I think it’s worth mentioning as it might change the way you design your app and affect the choice of tooling.
Fetching data
SPAs mostly load data asynchronously after components initially render — it renders its initial state, uses useEffect to send API calls asynchronously, and updates the state after it’s loaded, causing the with-data to render. Usually, your code would look something like this:
import React, { useEffect, useState } from "react";
const Component = () => {
const [isLoading, setIsLoading] = useState(false);
const [items, setItems] = useState([]);
useEffect(() => {
setIsLoading(true);
// fetch data then update state with returned items
fetchData.then((data) => {
setIsLoading(false);
setData(data);
});
}, []);
return (
<div>
{isLoading
? "Loading..."
: items.map((item) => <ItemComponent item={item} />)}
</div>
);
};
No asynchronous operations
Since SSR returns just the first render synchronously, it doesn’t trigger any async hooks and returns the initial HTML string. This would cause the app to take longer to fetch data, and the initial state would be visible for more time.
How to go around it?
There are many ways to go around it:
- Using a loading state/skeleton should also give the user an indicator that the data isn’t ready yet and render only when the data is available.
- Most routers provide a way to pre-load router data on server side. React router, for example, provides a way to access route loadData function on the server side, which can be used to resolve data and provide it to your application through context or some other way (https://v5.reactrouter.com/web/guides/server-rendering/data-loading).
- React frameworks, such as Next.js and Remix, each provides their own way to preload route data and then pass it to the component while rendering.
- Next.js provides a getServerSideProps function, which gets pre-loaded on the server and then passed to the page as props.
- Remix provides a loader constant which is used to preload data for every page on the server, and the page can use the useLoaderData hook to consume the data.
Generating unique IDs, props, or text that rely on web APIs
Generating keys or unique IDs should come from props to make sure it’s the same as long as props don’t change. However, in some cases, mostly for attribute or a11y attributes like aria-labelledby, aria-describedby, etc., you need to link elements together with a unique ID.
What if you have some text that relies on web APIs like "LocalStorage”? do you expect this to return the same result on both the server side and client side?
Markup mismatch
Using any random ID generator or even Math.random() anywhere in your code would mostly return two different values if you run it on the server side and client side. Also, as the user’s LocalStorage content is not available on the server side, you might still get a different text to render, and when the app gets hydrated on the client side, you'll most certainly get one of these warnings.
Warning: Text content did not match. Server: "1st instance" Client: "7th instance"
Warning: Prop `id` did not match. Server: "IDR5O" Client: "SCVR7"
How to go around this?
React provides a new hook called useId which you can use to guarantee the same Id is generated on both the server side and the client side.
const Checkbox = () => {
const id = useId();
return (
<>
<label htmlFor={id}>Do you like React?</label>
<input id={id} type="checkbox" name="react"/>
</>);
};
But what about the other case where something is reliant on a client-side API? one of the things you can do there is delay the whole set of these values until after the first render. You can move the whole logic that calculates this into useEffect where you would be sure it will only run on the client side and use an initial state instead that doesn’t rely on the client-side environment.
Adding SSR to your app is beneficial, but it also comes with its own challenges. It’s very important for you to wrap your head around those challenges before starting to build UI components.