Our journey to make styled-components work with server-side rendering
July 08, 2020   ·  4min

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!