In this article, you will learn about our journey to make styled-components
work with server-side rendering and the many challenges we faced along the way.
To give you some context before starting, we have a website
application that uses @prontopro/ui-toolkit
, which is a separate package representing our UI library.
@prontopro/ui-toolkit
is using styled-components
to create UI components.
Our goal is to server-side render pages of website
, which means that we also need to server-side render the CSS styles of each page.
Server-side rendering styles
We want all the CSS styles generated by styled-components
to be in the <head>
of the HTML that we send as response to the browser.
Normally, styled-components
assumes that it's executing in a browser and so it produces CSS styles and injects them directly into the document.
However, this is not the case when doing server-side rendering.
Therefore, to have the CSS styles in the server-side rendered HTML, we need some way to tell styled-components
that it's on the server and that it should just collect all the styles into a string rather than trying to inject them in the document.
We can then take that string and inject it in the <head>
of the HTML that we send as response.
Details on how to do this are explained in the official styled-components documentation.
So, we basically needed to change server code like this:
app.get('/*', (req, res) => {
const body = renderToString(<App />)
res.send(html(body))
});)
to this
app.get('/*', (req, res) => {
const sheet = new ServerStyleSheet()
const body = renderToString(sheet.collectStyles(<App />))
const styleTags = sheet.getStyleTags()
sheet.seal()
res.send(html({ body, styleTags }))
});)
In the html
function, we just placed styleTags
inside the <head>
and everything worked.
Except one thing.
In the console, we could see a warning along these lines:
Warning: Prop `className` did not match.
Server: "sc-EHOje sc-bZQynM hJQXa"
Client: "sc-EHOje sc-bZQynM dZGCiq"
During the client-side hydration of server-side rendered content, everything should match — there should be no differences between client and server. However, this warning indicated that there were differences in the class names.
Dealing with class name mismatches using babel-plugin-styled-components
The official styled-components
documentation mentions that there could be class name mismatches between client and server, and to avoid them, we should use babel-plugin-styled-components
.
Because we use styled-components
both in the @prontopro/ui-toolkit
library and in the website
application, we should add that Babel plugin to both.
For the website
build, we are using Neutrino with the React preset, so we added the Babel plugin like this:
module.exports = {
use: [
react({
babel: {
plugins: [
[
require.resolve('babel-plugin-styled-components'),
{ ssr: true },
],
...
That solved the problem for all the styled components that we create in the website
application.
We needed to do the same thing for our UI library, which is using Rollup for the build. So, we updated the Rollup configuration to include the Babel plugin:
export default {
plugins: [
babel({
plugins: [
[
'babel-plugin-styled-components',
{ ssr: true },
],
...
However, this seemed to have no effect — we were still getting the warning:
Warning: Prop `className` did not match.
Server: "sc-EHOje sc-bZQynM hJQXa"
Client: "sc-EHOje sc-bZQynM dZGCiq"
So, we wanted to first understand what exactly babel-plugin-styled-components
is doing.
We learned that it finds all styled components, and attaches to them a .withConfig({ componentId: "sc-..." })
, which ensures that the component ID is always the same.
We looked at the build result of our UI library, and saw no .withConfig({ componentId: "sc-..." })
attached to the styled components.
Something was not right.
After some investigation, we found that the babel
option that we use in Rollup had no effect on our TypeScript files, as by default they are ignored. We needed to explicitly specify the extensions for TypeScript:
babel({
extensions: ['.ts', '.tsx'],
plugins: [
[
'babel-plugin-styled-components',
{ ssr: true },
],
...
But even after doing this, the styled-components
Babel plugin was still having no effect.
After many hours of searching what might be the cause of the issue, we tried downgrading the version of the package from 1.10.7
to 1.10.2
, and that made it work!
So, it seems that one of the latest patch releases of babel-plugin-styled-components
introduced a regression. After some trial, we found that it was 1.10.6
which introduced the regression, so we endeed up using 1.10.5
. (Someone already created an issue about babel-plugin-styled-components
not working with Rollup, but it didn't exist when we first faced the problem.)
However, even though all our styled components now correctly had .withConfig({ componentId: "sc-..." })
, we were still getting the warning — the classes that were given to the <div>
resulting from our <Column>
were sc-EHOje sc-bZQynM hJQXa
server-side and sc-EHOje sc-bZQynM kVlBAn
client-side. So, the last class didn't match and we had no idea why.
Ensuring server and client rendering consistency to avoid class name mismatches
To tackle the issue, we first needed to understand where that last class was coming from. So, after a little bit of searching, we found this article which explains the inner-workings of styled-components
.
In that article, it's mentioned that styled-components
renders an element with 3 classes:
- The
className
coming from the props passed by the parent component. componentId
- The unique ID associated with each styled component.generatedClassName
- A generated class name that depends, among other things, on the unique props passed to the styled component instance.
So, in our case the problem was not the className
passed via props, nor the componentId
, which we ensured is always the same by using babel-plugin-styled-components
. The problem was the generatedClassName
, which is different when the props passed to a styled component are different.
Therefore, somewhere in our flow we were passing different props to our Column
on the server and on the client.
These kinds of things usually occur when passing props based on a condition like typeof window !== undefined
.
After having a look at the implementation of Column
, we found that we were indeed indirectly using a useViewportDimensions
hook to get some of the props, and that hook, which returns the width and height of the window and screen, was returning widths and heights set to zero on the server and non-zero on the client.
// Details are omitted for the sake of simplicity
const getWindowDimensions = () => ({
windowHeight: side.client ? document.documentElement.clientHeight : 0,
windowWidth: side.client ? document.documentElement.clientWidth : 0,
})
const getScreenDimensions = () => ({
screenHeight: side.client ? window.screen.height : 0,
screenWidth: side.client ? window.screen.width : 0,
})
export const useViewportDimensions = () => {
const [dimensions, setDimensions] = useState({
...getWindowDimensions(),
...getScreenDimensions(),
})
// ...
return dimensions
}
You can think of side.client
as typeof window !== undefined
.
This explained why the generatedClassName
was different between the client and the server.
To fix this, we modified the hook to always return a fixed width and height on the first render, and then rely on useEffect
, which is executed only on the client, to set the actual widths and heights.
// Details are omitted for the sake of simplicity
const defaultWidth = 375
const defaultHeight = 650
const getWindowDimensions = () => ({
windowHeight: document.documentElement.clientHeight,
windowWidth: document.documentElement.clientWidth,
})
const getScreenDimensions = () => ({
screenHeight: window.screen.height,
screenWidth: window.screen.width,
})
export const useViewportDimensions = () => {
const [dimensions, setDimensions] = useState({
screenHeight: defaultHeight,
screenWidth: defaultWidth,
windowHeight: defaultHeight,
windowWidth: defaultWidth,
})
useEffect(() => {
setDimensions({ ...getScreenDimensions(), ...getWindowDimensions() })
// ...
}, [])
return dimensions
}
We chose mobile dimensions as the default value to avoid re-renders on mobile devices due to the rendering of different styles caused by the big change in the dimensions returned by useViewportDimensions
.
Conclusion
You've seen the issues we faced when we tried to make styled-components
work with SSR and how we fixed them by using babel-plugin-styled-components
and by ensuring consitency between server and client rendering.
Hope you enjoyed this article and that you find it useful if you're implementing SSR with React and styled-components
!