In the last few chapters, we focused on various integration techniques and discussed their strengths and weaknesses. We grouped them into two categories: server-side and client-side. Integration on the server makes it possible to ship a page that loads quickly and adheres to the principles of progressive enhancement. Client-side integration enables building rich user interfaces where the page can react to user input instantly.
Broad framework support for universal rendering made building applications that run server- and client-side a lot easier for developers. But what do we need to do to integrate multiple universal applications into a big one?
You’ve already acquired the necessary building blocks. We can combine the client- and server-side composition and routing techniques from the last few chapters to make this happen. Figure 8.1 illustrates how our puzzle pieces fit together.
Note In this chapter, we assume that you’re already familiar with the concept of universal rendering and know what hydration is. If not, I recommend reading this blog post for a quick introduction. 1 If you want do dive deeper, you can also check out the book Isomorphic Web Applications. 2
In this chapter, we’ll upgrade our product detail page. We’ll implement universal rendering for all micro frontends and than apply the required integration techniques to make the site work as a whole.
Since Team Decide added Team Checkout’s Buy button to the product page, tractor sales skyrocketed. Now hundreds of orders from all over the world arrive every hour. The team behind The Tractor Store was pretty overwhelmed by this success. They had to ramp up their production and logistics capabilities to keep up with the demand. But not everything has been rosy since then. Over recent weeks, the development teams struggled with some serious issues. One day Team Checkout shipped a release of their software that triggered a JavaScript error in all Microsoft Edge browsers. Due to this bug, the Buy button was missing on the page. Sales for that day were down by 34%. This incident showed a significant quality issue, and the team took measures so that this kind of problem wouldn’t strike again.
But this is not the only problem. The product page integrates the Buy button micro frontend using client-side composition via Web Components. The Buy button is not part of the initial markup. Client-side JavaScript renders it. While it loads, the user sees an empty spot where the Buy button will appear after a delay. In local development, this delay is not noticeable. But in the real world, on lower-end smartphones and non-optimal network conditions, it takes a considerable amount of time. Adding new features to the Buy button made this effect even worse. Figure 8.2 shows how the product page looks when JavaScript fails or hasn’t finished loading yet.
The teams decide to switch to a hybrid integration model. Using SSI for server-side composition and also keeping the Web Components composition. This way, the first-page load can be fast, and client-side updating and communication is still possible. Let’s look at this combination.
In chapter 5, Team Checkout wrapped its Buy button micro frontend into a Custom Element. The browser receives the following HTML markup.
... <checkout-buy sku="fendt"></checkout-buy> ...
Since checkout-buy
is a custom HTML tag, the browser treats it as an empty inline element. At first, the user sees nothing. Client-side JavaScript creates the actual content (a button with a price) and renders it as a child. Then the final DOM structure in the browser looks like this:
... <checkout-buy sku="fendt"> <button type="button">buy for $54</button> </checkout-buy> ...
It would be great if we could ship the button
content already with the initial markup. Sadly, Web Components don’t have a standard way to render server-side. 3
TIP You can find the sample code for this task in the 16_universal
folder. It essentially combines the example code from 05_ssi
with 08_web_components
.
Since there is no standard way of doing it, we need to be creative. In this example, we will use the SSI technique you learned in chapter 4 for adding server-side composition to the Web Components approach. This way, we prepopulate the Web Components’ internal markup. Figure 8.3 shows our folder structure. Team Decide adds an SSI directive as the child to the Buy button’s Custom Element.
... <checkout-buy sku="fendt"> ❶ <!--#include virtual="/checkout/fragment/buy/fendt" --> ❷ </checkout-buy> ...
❶ Client-side Custom Element definition owned by Team Checkout. The associated code runs in the browser and renders/hydrates the micro frontend.
❷ Nginx replaces this SSI directive with the content that’s returned by the endpoint specified in virtual. Team Checkout owns this endpoint.
The preceding code of Team Decide’s product page now combines client- and server-side composition. The Nginx web server replaces the SSI directive with the <button>
markup, which Team Checkout generates when calling the /checkout/fragment/buy/fendt
endpoint. Our example simulates this by serving a static HTML file.
<button type="button">buy for $54</button>
In practice, you’d use a library with server-rendering capabilities to dynamically generate a response in a Node.js environment. For a React-based application, you’d call ReactDOMServer.renderToString
(<CheckoutBuy
/>)
and return its result. Here <CheckoutBuy />
would be the React-based micro frontend application. The assembled product page markup that reaches the browser looks like this:
...
<checkout-buy sku="fendt">
<button type="button">buy for $54</button> ❶
</checkout-buy>
...
❶ Nginx replaced the SSI directive with the actual content.
The browser is now able to show the button instantly. The associated Custom Element code runs when the JavaScript finishes loading. It hydrates the micro frontend--making sure that the markup is correct and attaching events for further interaction.
Team Checkout’s client-side code for the Buy button looks like this.
const prices = { porsche: 66, fendt: 54, eicher: 58 }; class CheckoutBuy extends HTMLElement { connectedCallback() { const sku = this.getAttribute("sku"); this.innerHTML = ` ❶ <button type="button">buy for $${prices[sku]}</button> ❶ `; ❶ this.querySelector("button").addEventListener("click", () => { ❷ ... ❷ }); ❷ } ... } window.customElements.define("checkout-buy", CheckoutBuy); ...
❶ Renders the markup client-side. This is a “dumb” implementation which replaces all existing markup even if it might already be correct. In a real application, you’d use something more clever and performant like DOM-diffing.
❷ Adding event listeners to be able to react to user input
The code is identical to the examples we used in chapter 5. The component renders its internal markup inside itself and attaches all required event handlers. We again use a simplified implementation here. No client-server code reuse, no DOM diffing. But you get the picture.
When you use something like React, this is the place where you’d call ReactDOM.hydrate (<CheckoutBuy
/>, this)
, where <CheckoutBuy />
is the React application for the button and this
is the reference to the Custom Element. The call instructs the framework to pick up the existing server-generated markup and hydrate it.
Figure 8.4 shows the complete process we went through, starting with the server-side markup generation at the bottom and ending with the initialization of the Buy button’s Custom Element in the DOM.
The integration works. Run the example with |
Notice Team Checkout’s mini-cart and Team Inspire’s recommendation fragment. The integration for these fragments works the same way as for the Buy button.
Have a look at the server-logs in the console. You can see how Nginx requests the individual SSI fragments needed for the page.
See how the price on the Buy button updates client-side when you select the platinum edition.
Clicking the button triggers the checkmark animation and updates the mini-cart.
Disable JavaScript in your browser to simulate how the page looks when the client-side code fails or isn’t loaded yet.
Let’s take a quick look at the contract for including a fragment from another team. Here is the definition Team Checkout provides:
Since we are combining two integration techniques, the team offering the micro frontend needs to provide both: the Custom Element definition and the SSI endpoint, which delivers the server-side markup. The team using the micro frontend also needs to specify both. In our example Team Decide uses this code:
<checkout-buy sku="fendt"> <!--#include virtual="/checkout/fragment/buy/fendt" --> </checkout-buy>
These three lines include a lot of redundancy. To reduce friction, it’s a good idea to establish a project-wide naming schema. This way, tag names and endpoints all look alike, and teams can use a generic template for including a fragment. Figure 8.5 shows how a schema might look.
This is, of course, not the only way to build a universal integration. Instead of SSI and Web Components, you can also combine other techniques. Integrating server-side with ESI or Podium and adding your client-side initialization on top would also work.
Are you looking for a batteries-included solution? Then you could try the Ara Framework. 4 Ara is a relatively young micro frontends framework, but it’s built with universal rendering in mind. It brings its own SSI-like server-side assembly engine written in Go. Client-side hydration works through custom initialization events. Examples for running a universal React, Vue.js, Angular, or Svelte application exist.
Does your application need to have a fast first-page load? Your user interface should be highly interactive, and your use case requires communication between the different micro frontends. Then there is no way around a universal composition technique like you’ve seen in this chapter.
But the fact that one team wants to use universal rendering does not mean that you need a client-side composition technique. Let me give you an example.
Team Decide owns the product page and includes a header micro frontend (fragment), which Team Inspire owns. The two applications (product page and header) do not need to communicate with each other. Here a simple server-side composition is sufficient. Both teams can adopt universal rendering inside of their micro frontends if it helps their goal. But they don’t have to. If the header has no interactive elements, a pure server rendering is sufficient. They can add client-side rendering later on if their use case changes. The other team does not have to know about it. From an architectural perspective, universal rendering inside a team is a team-internal implementation detail.
Universal composition combines the benefits of server- and client rendering. But it also comes with a cost. Setting up, running, and debugging a universal application is more complicated than having a pure client- or server-side solution. Applying this concept on an architecture level with universal composition doesn’t make it easier. Every developer needs to understand how integration on the server and hydration on the client works. Modern web frameworks make building universal applications easier. Adding a new feature is usually not more complicated. But the initial setup of the system and onboarding of new developers takes extra time.
Is it possible to combine the application shell model from chapter 7 with universal rendering? Yes, in this chapter, we combined client- and server-side composition techniques to run multiple universal applications in one view. You could also combine client- and server-side routing mechanisms to create a universal application shell. However, this is not a trivial undertaking, and I haven’t seen production projects that are doing this right now.
The single-spa project plans to add server-side rendering support. But at the time of writing this book, this feature hasn’t been implemented yet. 5
Let’s take a look at our beloved comparison chart in figure 8.6 for the last time. As stated before, running a universal composition setup is not trivial and introduces extra complexity. Since it builds on the existing client- and server-side composition techniques, it also does not introduce extra technical isolation. But this approach shines when it comes to user experience. It’s possible to achieve the page-load speeds of server-rendered solutions, and it also enables building highly interactive features that directly render in the browser.
To keep this chart readable, I’ve omitted the theoretical universal unified SPA option. It’s by far the most complicated approach, but it would rank even higher on the interactivity scale since it eliminates all hard page transitions.
Universal rendering combines the benefits of server and client rendering: fast first-page load and quick response to user input. To leverage this potential in a micro frontends project, you need to have a server- and client-side composition solution.
You can use SSI together with Web Components as a composition pattern.
Each team must be able to render its micro frontend via an HTTP endpoint on the server and also make it available via JavaScript in the browser. Most modern JavaScript frameworks support this.
On the first page load, a service like Nginx assembles the markup for all micro frontends and sends it to the browser. In the browser, all micro frontends initialize themselves via JavaScript. From that point on, they can react to user input entirely client-side.
Currently, there’s no web standard to server-render a Web Component. But there are custom solutions to define ShadowDOM declaratively. In our example, we use the regular DOM to prepopulate the Web Components content on the server.
It’s possible to implement a universal application shell to enable client- and server-side routing. However, this approach comes with a lot of complexity.
1.See Kevin Nguyen, “Universal Javascript in Production--Server/Client Rendering,” Caffeine Coding, http://mng.bz/04jl.
2.Elyse Kolker Gordon, Isomorphic Web Applications--Universal Development with React, http://mng.bz/K2yZ.
3.There are custom solutions available in projects like Skate.js or Andrea Giammarchi’s project Heresy. But since the W3C spec defines Shadow DOM as a pure client-side concept, we don’t have a web standard to build upon for proper hydration.
18.190.217.134