Follow us on a navigational journey through the complexities of SSR micro-frontend architecture in large-scale e-commerce projects and our exploration of a custom SSR server integrated with Nginx SSI and Vite.
In our role as technical consultants, we were part of diverse projects written in React.js, dealing with unique architectural challenges, all sharing a critical common factor: the need for a micro-frontend architecture that enables multiple teams to deliver distinct components developed using heterogeneous technologies, seamlessly integrated into a cohesive application.
In complex customer-facing websites where SEO and initial loading times are critical, Server-Side Rendering (SSR) is essential for enhancing performance and potentially boosting conversions. Yet, mainstream SSR solutions like Next.js or Remix seem to lack robust options for building micro-frontends, and the Module Federation approach, although capable, presents its own share of limitations.
In this blog post, we will explore how we dealt with SSR in an architecture centered around Nginx SSI. We’ll demonstrate how developing a custom SSR server can break beyond typical framework constraints, providing enhanced flexibility in web application design and performance.
Large-scale web applications, by virtue of their size and complexity, often involve managing different functionalities developed and maintained by various autonomous teams, each with a different expertise and technological background.
Imagine a large-scale e-commerce site serving various independent functionalities — from product details and customer reviews to virtual shopping carts. All these functionalities require specialized expertise for development and maintenance. It’s not uncommon, therefore, to find different teams dedicated to different components.
Coordinating these independent components to work cohesively is a significant challenge. This is where micro-frontends1 emerge as a potential solution, enabling each team to develop and deploy their parts independently without interrupting the overall user experience.
However, navigating the landscape of various methods to fetch these independent components server-side and integrate them together can be daunting. The task poses an even greater challenge when the aim is to enhance performance and SEO by upholding Server-Side Rendering.
During our most recent projects, we tackled these complexities in an architecture built around the Nginx's Server-Side Include (SSI) feature2
The setup consists of multiple independent "pages", each developed using whatever technology better suits the case. A reverse proxy or load balancer upfront analyses the URL's segments to direct the user to the appropriate page app.
These "page" apps can act as containers for "fragments" — individual micro-apps responsible for generating distinct HTML snippets for any particular, independent, area or functionality of the page (like the shopping cart or the customer reviews area). The integration occurs at the moment of the request, as the web server examines the parent HTML seeking unique HTML comments that reference the web server responsible for providing the fragment snippet. The snippet is then retrieved from the server and assimilated into the page before this is dispatched to the user. The same strategy can also be used by the fragments to include other fragments in a nested structure.
In this architectural setup, the key requirement is to preserve the SSR methodology, to enhance the preliminary page load time by pre-rendering all the component HTML snippets on the server, thereby accelerating the delivery of the fully composed page to the user.
When discussing SSR technologies in React.js, one is immediately led to think of frameworks like Next.js and Remix, which simplify the implementation of SSR applications.
However, while these solutions might work very well in a traditional monolithic application, where a single app owns the entire DOM, they become problematic when multiple applications are expected to cohabitate in the same DOM tree, as in the case of the fragments in the proposed architecture.
From a practical standpoint, the issue is that frameworks like Next.js or Remix rely on global objects attached to the DOM document for their purposes, and these globally defined elements may interfere with or even overwrite functionalities of other applications rendered on the same page (there’s an ongoing discussion3 on the Remix repository where the issue is explained in more details).
Given that this issue appears to be deeply rooted in the working principles of these frameworks, it doesn't seem feasible to configure their behavior or namespace these global objects to avoid conflicts, making these frameworks not well suited for our needs.
You may have heard of Module Federation4, a feature introduced in Webpack 5 that allows separate builds to act like containers and expose and consume code among themselves, creating a single, unified application.
This technology has already proven to be a viable approach for composing micro-frontend apps in Next.js at build-level, as described, for example, in this blog post5 by Alibek.
However, as robust as it may be, Module Federation has a few notable drawbacks. For starters, it requires a strict dependency on Webpack for all fragment builds, effectively restricting the flexibility that characterizes a micro-frontend architecture — the freedom to utilize the optimal technology for each individual component.
Moreover, since the composition happens at build level, we lose the flexibility and independence of individual micro-frontends, since we need to coordinate teams to maintain consistency in interfaces and release schedules.
In addition, navigating shared dependencies using Module Federation can often become a complex affair. This can particularly emerge as a challenge when disparate components or fragments, built at different times or by different teams, rely on divergent or incompatible versions of a shared resource. Frequently, this can manifest as subtle, difficult-to-trace bugs or conflicts that prove time-consuming to debug and resolve.
Realizing that these frameworks couldn't adequately address our needs, we investigated their underlying SSR mechanisms to understand how they operate "under the hood" and explore whether we could tailor the SSR behavior to implement our fragments.
The Vite documentation, particularly the SSR section6, is a good starting point for understanding how an SSR application works. Since Remix is built directly on Vite, it should not be hard to believe it does something very similar to what is described there under-the-hood.
These are, in brief, the steps to follow:
entry-server.tsx
. This module employs the Server React Node APIs like renderToPipeableStream
to convert the React.js app into an HTML snippet pipelined in the server response.entry-clients.tsx
, on the client. This particular module applies the hydrateRoot method, giving life to HTML elements and making them interactively responsive.
export async function render() {
const html = ReactDOMServer.renderToString(
<React.StrictMode>
<MyApp />
</React.StrictMode>,
)
return { html }
}
entry-server.tsx
ReactDOM.hydrateRoot(
document.getElementById('root')!,
<React.StrictMode>
<MyApp />
</React.StrictMode>,
)
entry-client.tsx
With this Node.js endpoint in place serving the fragment, the parent component (both page or another fragment) can specify a SSI directive in its HTML that will be managed by Nginx and will query the fragment’s endpoint and subsequently inject the returned HTML into the parent before delivering it to the user.
At some point, we inevitably encountered the need to fetch data before the app in pre-rendered on the server. This data could encompass anything from user information and product details to dynamic content required for the initial render of the component (e.g., localization).
Fetching data in a client-side rendering scenario is straightforward, and several libraries, like TanStack Query, can help address the typical challenges.
However, in an SSR scenario, in addition to having the data available before the rendering begins, we also need to ensure this server-fetched data is accessible on the client side, for the hydrate function to understand and recreate the interactive elements correctly. One way to achieve this is by embedding the data in the server-generated HTML itself, usually as a JSON blob. The client-side script can then pick up this data from the DOM, avoiding the need for a redundant network request from the client.
async function getData() {
const data = // fetch the needed data from external resources
return { data }
}
export async function render() {
const data = await getData()
const html = ReactDOMServer.renderToString(
<React.StrictMode>
<MyApp data={data} />
<script
id="__MYAPP_DATA__"
type="application/json"
dangerouslySetInnerHTML={{ __html: devalue.stringify(data) }}
/>,
)
return { html }
}
async function initApp() {
try {
const dehydratedData = document.getElementById('__MYAPP_DATA__')?.textContent
return devalue.parse(dehydratedData!)
} catch (e) {
console.log('Failed to hydrate data', e)
throw e
}
}
initApp().then(data => {
ReactDOM.hydrateRoot(
document.getElementById('root')!,
<React.StrictMode>
<MyApp data={data} />
</React.StrictMode>,
)
})
Note that we’re using https://github.com/Rich-Harris/devalue here instead of JSON.stringify/JSON.parse to protect us against XSS attacks and provide better support for complex types like Dates or Maps.
This is an example of where the typical frameworks prove inadequate. By attaching server-side fetched data on a global context on the DOM document without namespacing them, multiple applications rendered on the same document may overwrite the data fetched by other applications, interfering with their rendering.
Instead, by handling this process manually, we can assign a unique, app-specific ID to our data (see the
__MYAPP_DATA__
id assigned to the script tag in the example above), making it possible for other micro-frontends to coexist with our app and retrieve only their data on the client.
A potential snag in the architecture we've discussed so far is the repeated inclusion of underlying libraries like React.js, whose code is bundled and downloaded with every independent piece of micro-frontend using them. This means multiple micro-frontends developed using the same framework will require downloading the framework’s JavaScript code multiple times for a single page, impacting the initial loading time.
A possible solution to this issue includes instructing the micro-frontends not to directly embed the scripts of the shared technology, such as React and ReactDOM, in the final build. Instead of embedding, the micro-frontends should import these scripts from an externally hosted source, like a Content Delivery Network (CDN).
By loading the scripts externally, the browser can recognize and deduplicate the requests for these external modules from different micro-frontends or exploit its caching mechanisms to fetch these modules only once per page.
We've explored a versatile SSR micro-frontend architecture, showing how it can bring flexibility to complex web applications and even allow different parts built with multiple technologies to work together seamlessly without sacrificing load time and performance.
The proposed architecture offers an alternative to traditional approaches like Module Federation, emphasizing how it can better maintain the teams' independence, whether in operational aspects or critical processes like the release cycle and build pipelines.
If our journey through implementing this architecture resonated with you and you could use some guidance, don't hesitate to reach out. We can provide the necessary support, helping you easily navigate the process.
[1] https://micro-frontends.org/
[2] https://nginx.org/en/docs/http/ngx_http_ssi_module.html
[3] https://github.com/remix-run/remix/discussions/9156
[4] https://webpack.js.org/concepts/module-federation/
[5] https://alibek.dev/micro-frontends-with-nextjs-and-module-federation
[6] https://vitejs.dev/guide/ssr
[7] https://www.gatsbyjs.com/docs/conceptual/react-hydration/#what-is-hydration
Vincenzo joined Buildo as a Fullstack engineer in 2016, becoming the Frontend tech lead of the company. Passionate about frontend and backend technologies and music.
Stai cercando un partner affidabile per sviluppare la tua soluzione software su misura? Ci piacerebbe sapere di più sul tuo progetto.