DEV Community

Cover image for Case Study: React partial SSR with SFCC
Lyubomir Angelov
Lyubomir Angelov

Posted on • Originally published at calendar.perfplanet.com

Case Study: React partial SSR with SFCC

Case Study: React partial SSR with SFCC

React partial SSR in restricted cloud CMSs

This case study will show a way to implement partial Server-Side Rendering (SSR) and achieve performance gains without big investments for middleware in cloud-based platforms.

All cloud CMSs like Salesforce Commerce Cloud (SFCC) and Magento have their pros and cons. In those CMSs, we have lots of restrictions, but the major one for the purposes of this article is that we do not have access to the server, so we can't use Server-Side Rendering.

SFCC (ex Demandware) is cloud-based unified e-commerce platform for B2C retailers. Its core is written in Java but clients can extend it with JavaScript which they transpile to Java.

Our application is built with React and consumes JSON API returned from our headless SFCC.

If we want the performance gains of the SSR we have two options:

  1. Create middleware between the React app and the backend SFCC
  2. Create Partial SSR with what you have from the system

In our project, we can't go with option 1 because of budget, resources and time. That's why we chose option 2. This post describes what we did, but first, let's start with some background information.

React, SSR, Hydration, Progressive Hydration

If our goal is to make our React website fast, one of the best things we can do is to use Server-Side Rendering (SSR) for the whole application. For this to work, we need control over the server where the application is hosted and render the React app using, for example, Next.js or NodeJS.

SSR generates complete HTML for the page and returns it to the browser.

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app">
      <div id="app-root">
        <header>
          Logo, username etc.
          <nav>The navigation items</nav>
        </header>
        <div id="app-container">
          All the content between header and footer
        </div>
        <footer>
          Copyright and links stuff
        </footer>
      </div>
    </div>
  </body>
</html>

That's ok, now we just need to use hydration to let React attach all events handlers that it needs.

ReactDOM.hydrate(element, container[, callback])

With that, we get approximately 20% faster in most of the metrics - LCP, Speed Index and TTI - but we will get a little bit slower Time to first byte (TTFB) because the backend needs additional time to SSR the application.

But we can improve the app even further: we can apply React Progressive Hydration (which I won't discuss in detail, there are lots of articles about it:
Dan Abramov Progressive Hydration demo,
Progressive React,
SSR React and Hydration).

What is Progressive Hydration?

With Progressive Hydration React can attach only the events for elements that are visible in the initial viewport, so we can further reduce JavaScript's execution time.

Problems

Since we're using SFCC we are not able to do the SSR described above, that's why we had to think about what we can do in order to achieve similar results as if we had SSR.

Our Homepage and Category Landing Pages are pure HTML, CSS and a little bit of JavaScript that is created in the CMS from WYSIWYG editor, again the limitation of the platform. This content is created by the third party which is responsible for the whole dynamic content on the platform. Then this content (HTML, CSS, JS) is provided with JSON API that the React app gets and fills the app-container div.

Example:

let content = {
                "result": {
                    "html": "ENCODED HTML/CSS/JS from the WYSIWYG editor"
                }
            }
render() {
    return (
        <div dangerouslySetInnerHTML={ __html: content.result.html } />
    )
}

Because of that approach, the end result that the customers are seeing is this:

React no partial SSR

Problem one

What we can return directly from the backend is the HTML below, which is not enough for the React app to hydrate.

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app-shell">Static Header</div>
    <div id="app-container">
      Content between header and footer
    </div>
    <div id="app"></div>
  </body>
</html>

Problem two

In order to use React and the hydration mode, we must provide the whole DOM structure of the React-generated HTML.
It is React app, almost every HTML is generated by the React and the JSON API that he consumes. With that, we don't have for example the HTML of the <header> and <footer>. This is the maximum of what we can do as server-side generated HTML:

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app">
      <div id="app-root">
        <header></header>
        <div id="app-container">
          Content between header and footer
        </div>
        <footer></footer>
      </div>
    </div>
  </body>
</html>

If we return this HTML without the content of the <header> and <footer>, tags, React will throw an error, because it needs the whole DOM structure in order to attach the events and cannot fill the missing elements.

So what we did?

First of all, initially, we thought that we can just create the above HTML structure and React will fill the missing elements only but few hours and errors later we figured out that React needs whole React-generated HTML in order to hydrate.

Step One

Return what we have as HTML from the backend and the initial structure looks like that:

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app-shell">Static Header</div>
    <div id="app-container">
      Content between header and footer
    </div>
    <div id="app"></div>
    <script src="OUR_INITIAL_REACT_BUNDLES"></script>
  </body>
</html>

Step Two

Our initial App architecture is like this:

App.js

class App extends Component {
    render() {
        <div className='app-root' >
            <RouteList {...this.props} />
        </div>
    }
}

RouteList.js

class RouteList extends Component {
    render() {
        return (
            <React.Fragment>
                <Header />
                <div className="app-container">
                    <React.Suspense fallback={<span />}>
                    <Route exact path='/' render={(props) => <Category {...props} />} />
                    etc.
                    </React.Suspense>
                </div>
            </React.Fragment>
        )
    }
}

When React is ready and in RouteList we delete the app-container and app-shell divs from Step one and let our <Category /> component get again the HTML by making a request to the JSON API and render it.

Something like this:

class RouteList extends Component {
    componentDidMount() {
        let elem = document.getElementById('app-shell');
        elem.parentNode.removeChild(elem);
        let elem = document.getElementById('app-container');
        elem.parentNode.removeChild(elem);
    }

    render() {
        return (
            <React.Fragment>
                <Header />
                <div className="app-container">
                    <React.Suspense fallback={<span />}>
                    <Route exact path='/' render={(props) => <Category {...props} />} />
                    etc.
                    </React.Suspense>
                </div>
            </React.Fragment>
        )
    }
}

Then we have our first Partial SSR!

Step Three

The second step makes an additional request to get the same content that it's deleting, so we have changed the HTML returned from the first request:

<html>
  <head>
    Some meta, CSS, scripts, third-parties etc.
  </head>
  <body>
    <div id="app-shell">Static Header</div>
    <div id="app-loader"></div>
    <script>
    const appContainer = {
      html: '<div id="app-container">Content between header and footer</div>'
    }
    var appLoaderElement = document.getElementById('app-loader');
    appLoaderElement.innerHTML = decodeURIComponent(appContainer.html);
    </script>
    <div id="app"></div>
    <script src="OUR_INITIAL_REACT_BUNDLES"></script>
  </body>
</html>

Then again in RouteList component, we delete the app-loader div but the <Category /> component will check if appContainer is not empty and get the HTML from it and won't make an additional request. (Yup, we know, it is ugly.)

The result is this timeline:

react partial SSR white gap

(Final) Step Four

That white gap that you see above is ruining all our previous efforts, the SpeedIndex and LCP won't improve because of this gap and, more importantly, it's really awful for the user.

This is happening because we use React.lazy() and <Suspense> on routing level for components that are not <Header> and we are passing an empty <span> to the fallback attribute, so while React is waiting, the <Category /> to load, it shows empty span below the Header.

<React.Fragment>
  <Header />
  <div className="app-container">
    <React.Suspense fallback={<span />}>
      <Route exact path='/' render={(props) => <Category {...props} />} />
      etc.
    </React.Suspense>
  </div>
</React.Fragment>

To fix the gap we pass the JS global variable containing the HTML as the fallback:

<React.Fragment>
  <Header />
  <div className="app-container">
    <React.Suspense fallback={ <div dangerouslySetInnerHTML={ __html: decodeURIComponent(appContainer.html) } } >
      <Route exact path='/' render={(props) => <Category {...props} />} />
      etc.
    </React.Suspense>
  </div>
</React.Fragment>

dangerouslySetInnerHTML is not a good practice at all, it can expose you to cross-site-scripting attack but we don't have any other choice except to live with it for now :)

And the result:

react partial SSR without white gap

Performance improvements

While the above code is not the prettiest one, our performance improvements are significant for Homepage and Category Landing Pages:

FMP and FCP results

Lighthouse results

Thank you for reading this long article, I will be happy if you have any comments or suggestions :)

Oldest comments (0)