Chapter 13. Rendering Patterns

As we moved to more interactive websites, the number of events handled and the amount of content rendered on the client side grew, resulting in SPAs rendered primarily on the client, as in the case of React.js.

However, web pages can be as static or dynamic as the function they serve. We continue to serve a lot of static content on the web, for example, blog/news pages that you can generate on the server and push as-is to the clients. Static content is stateless, does not fire events, and does not need rehydration after rendering. Conversely, dynamic content (buttons, filters, search bar) has to be rewired to its events after rendering. The DOM has to be regenerated on the client side (virtual DOM). This regeneration, rehydration, and event handling functions contribute to the JavaScript sent to the client.

A Rendering pattern provides the ideal solution for rendering content for a given use case. The Rendering patterns in this table are popular:

Rendering patterns

Client-side rendering (CSR)

HTML is rendered completely on the client

Server-side rendering (SSR)

Dynamically rendering HTML content on the server before rehydrating it on the client

Static rendering

Building a static site to render pages on the server at build time

Incremental static generation

Being able to dynamically augment or modify a static site even after the initial build (Next.js ISR, Gatsby DSG)

Streaming SSR

Breaking down server-rendered content into smaller streamed chunks

Edge rendering

Altering rendered HTML at the edge before sending it on to the client

Hybrid rendering

Combines build-time, server, and client rendering to create a more flexible approach to web development (e.g., React Server Components and Next.js App Router)

Partial hydration

Only hydrating some of your components on the client (e.g., React Server Components and Gatsby)

Progressive hydration

Controlling the order of component hydration on the client

Islands architecture

Isolated islands of dynamic behavior with multiple entry points in an otherwise static site (Astro, Eleventy)

Progressive enhancement

Making sure an app is functional even without JavaScript

This chapter introduces some of these Rendering patterns and will help you decide which pattern is most suitable for your needs. It will help you make foundational decisions such as:

  • How and where do I want to render content?

  • Should content be rendered on the web server, on the build server, on an edge network, or directly on the client?

  • Should content be rendered all at once, partially, or progressively?

Importance of Rendering Patterns

Choosing the most suitable Rendering pattern for a given use case can make a world of difference to the developer experience (DX) you create for the engineering team and the UX you design for your end users. Choosing the correct pattern could lead to faster builds and excellent loading performance at low processing costs. On the other hand, a wrong choice of pattern can kill an app that could have brought to life a great business idea.

To create great UX, we must optimize our apps for user-centric metrics, such as the Core Web Vitals (CWV):

Time to First Byte (TTFB)

Time it takes for a client to receive the first byte of page content

First Contentful Paint(FCP)

Time it takes the browser to render the first piece of content after navigation

Time to Interactive (TTI)

Time from when the page starts loading to when it responds quickly to user input

Largest Contentful Paint (LCP)

Time it takes to load and render the page’s main content

Cumulative Layout Shift (CLS)

Measures visual stability to avoid unexpected layout shift

First Input Delay (FID)

Time from when the user interacts with the page to the time when the event handlers can run

The CWV metrics measure parameters most relevant to UX. Optimizing the CWV can ensure a great UX and optimal search engine optimization (SEO) for our apps.

To create a great DX for our product/engineering teams, we have to optimize our development environments by ensuring faster build times, easy rollbacks, scalable infrastructure, and many other features that help developers succeed:

Fast build times

The project should build fast for quick iteration and deployment.

Low server costs

The website should limit and optimize the server execution time to reduce execution costs.

Dynamic content

The page should be able to load dynamic content performantly.

Easy rollbacks

You can quickly revert to a previous build version and deploy it.

Reliable uptime

Users should always be able to visit your website through operational servers.

Scalable infrastructure

Your project may grow or shrink without facing performance issues.

Setting up a development environment based on these principles enables our development teams to build a great product efficiently.

We have now built quite a long list of expectations. But, if you choose the correct Rendering pattern, you can get most of these benefits automatically.

Rendering patterns have come a long way, from SSR and CSR to highly nuanced patterns discussed and judged today on different forums. While this can get overwhelming, it’s important to remember that every pattern was designed to address specific use cases. A pattern characteristic beneficial for one use case can be detrimental in the case of another. It is also quite likely that different types of pages require different Rendering patterns on the same website.

The Chrome team has encouraged developers to consider static or SSR over a full rehydration approach. Over time, progressive loading and rendering techniques may help strike a good balance of performance and feature delivery when using a modern framework.

The following sections cover different patterns in detail.

Client-Side Rendering

We have already discussed CSR with React in the previous chapter. Here is a brief overview to help us relate it to the other Rendering patterns.

With React CSR, most of the application logic is executed on the client, and it interacts with the server through API calls to fetch or save data. Almost all of the UI is thus generated on the client. The entire web application is loaded on the first request. As the user navigates by clicking on links, no new request is generated to the server for rendering the pages. The code runs on the client to change the view/data.

CSR allows us to have an SPA that supports navigation without page refresh and provides a great UX. As the data processed to change the view is limited, routing between pages is generally faster, making the CSR application seem more responsive.

As the complexity of the page increases to show images, display data from a data store, and include event handling, the complexity and size of the JavaScript code required to render the page will also increase. CSR resulted in large JavaScript bundles, which increased the FCP and TTI of the page. Large payloads and a waterfall of network requests (e.g., for API responses) may also result in meaningful content not being rendered fast enough for a crawler to index it. This can affect the SEO of the website.

Loading and processing excess JavaScript can hurt performance. However, some interactivity and JavaScript are often required, even on primarily static websites. The rendering techniques discussed in the following sections try to find a balance between:

  • Interactivity comparable to CSR applications

  • SEO and performance benefits that are comparable to SSR applications

Server-Side Rendering

With SSR, we generate the HTML for every request. This approach is most suitable for pages containing highly personalized data, for example, data based on the user cookie or generally any data obtained from the user’s request. It’s also suitable for pages that should be render-blocking, perhaps based on the authentication state.

SSR is one of the oldest methods of rendering web content. SSR generates the complete HTML for the page content to be rendered in response to a user request. The content may include data from a data store or external API.

The connect and fetch operations are handled on the server. HTML required to format the content is also generated on the server. Thus, with SSR, we can avoid making additional round trips for data fetching and templating. As such, rendering code is not required on the client and the JavaScript corresponding to this need not be sent to the client.

With SSR, every request is treated independently and processed as a new request by the server. Even if the output of two consecutive requests is not very different, the server will process and generate it from scratch. Since the server is common to multiple users, the processing capability is shared by all active users at a given time.

A personalized dashboard is an excellent example of highly dynamic content on a page. Most of the content is based on the user’s identity or authorization level that may be contained in a user cookie. This dashboard shows only when a user is authenticated and possibly shows user-specific sensitive data that should not be visible to others.

The core principle for SSR is that HTML is rendered on the server and shipped with the necessary JavaScript to rehydrate it on the client. Rehydration is regenerating the state of UI components on the client side after the server renders it. Since rehydration comes at a cost, each variation of SSR tries to optimize the rehydration process.

Static Rendering

With static rendering, the HTML for the entire page gets generated at build time and does not change until the next build. The HTML content is static and easily cacheable on a content delivery network (CDN) or an edge network. CDNs can quickly serve the prerendered cached HTML to clients when they request a specific page. This considerably cuts down the time it would otherwise take to process the request, render HTML content, and respond to a request in a typical SSR setup.

This process is most suitable for pages that do not change often and display the same data no matter who requests them. Static pages like the “About us,” “Contact us,” and “Blog” pages for websites, or product pages for ecommerce apps are ideal candidates for static rendering. Frameworks like Next.js, Gatsby, and VuePress support static generation.

At its core, plain static rendering does not involve any dynamic data. Let us understand it using a Next.js example:

// pages/about.js

export default function About() {
 return <div>
   <h1>About Us</h1>
   {/* ... */}
 </div>
}

When the site is built (using next build), this page will be prerendered into an HTML file about.html accessible at the route /about.

You can have several variations of static rendering as follows:

Static generation of a listing page with dynamic data from a database

The listing page is generated on the server with the data. This is suitable for pages where the listing itself is not very dynamic. In Next.js, you can export the function getStaticProps() in the page component for this.

Static generation of detail pages with dynamic routes

Product pages or blog pages usually follow a fixed template with data populated in placeholders. In this case, individual pages can be generated on the server by merging the template with the dynamic data giving us several individual routes for each detailed page. The Next.js dynamic routes feature helps to achieve this using the getStaticPaths() function.

Static rendering with client-side fetch

This pattern is helpful for a reasonably dynamic listing page that should display fresh listings always. You can still use static rendering for the website to render the UI with a skeleton component where you want to place the dynamic listing data. Then, after the page has loaded, we can fetch the data using SWR. SWR (inspired by the Stale-While-Revalidate pattern) are React Hooks for data fetching. A custom API route is used to fetch the data from the CMS and return this data. The pregenerated HTML file is sent to the client when the user requests the page. The user initially sees the skeleton UI without any data. The client fetches the data from the API route, receives the response, and shows the listings.

The key highlights of static rendering include the following:

  • HTML gets generated at build time.

  • Easily cacheable by CDN/Vercel Edge Network.

  • Plain static rendering is best for pages that do not require request-based data.

  • Static with client-side fetch is best for pages that contain data that should refresh on every page load and is contained in stable placeholder components.

Incremental Static Regeneration

ISR is a hybrid of static and SSR because it allows us to prerender only certain static pages and render the dynamic pages on-demand when the user requests them. This results in shorter build times and allows automatic invalidation of the cache and regeneration of the page after a specific interval.

ISR works on two fronts to incrementally introduce updates to an existing static site after it has been built:

Allows addition of new pages

The lazy-loading concept is used to include new pages on the website after the build. This means that the new page is generated immediately on the first request. While the generation takes place, a fallback page or a loading indicator can be shown to the user on the frontend.

Update existing pages

A suitable timeout is defined for every page. This will ensure that the page is revalidated whenever the defined timeout period has elapsed. The timeout could be set to as low as 1 second. The user will continue to see the previous version of the page until the page has finished revalidation. Thus, ISR uses the stale-while-revalidate strategy, where the user receives the cached or stale version while the revalidation takes place. The revalidation occurs entirely in the background and does not need a full rebuild.

On-Demand ISR

In this variation of ISR, the regeneration occurs on certain events rather than at fixed intervals. With regular ISR, the updated page is cached only at the edge nodes that have handled user requests for the page. On-demand ISR regenerates and redistributes the page across the edge network so that users worldwide will automatically see the most recent version of the page from the edge cache without seeing stale content. We also avoid unnecessary regenerations and serverless function calls, reducing operational costs compared to regular ISR. Thus on-demand ISR gives us performance benefits and a great DX. On-demand ISR is best for pages that should be regenerated based on specific events. It allows us to have fast and dynamic websites that are always online at a reasonable cost.

Summary of Static Rendering

Static rendering is an excellent pattern for websites where HTML can be generated at build time. We have now covered different variations of static generation, each of which is suitable for different use cases:

Plain static rendering

Best for pages that do not contain dynamic data

Static with client-side fetch

Best for pages where data should refresh on every page load and which have stable placeholder components

Incremental static regeneration

Best for pages that should be regenerated on a certain interval or on-demand

On-demand ISR

Best for pages that should be regenerated based on certain events

There are use cases where static isn’t the best option. For example, SSR is ideal for highly dynamic, personalized pages that are different for every user.

Streaming SSR

With SSR or static rendering, you can reduce the amount of JavaScript so that the time taken for the page to become interactive (TTI) is closer to the time for FCP. Streaming the contents can reduce the TTI/FCP further while still server-rendering the application. Instead of generating one large HTML file containing the necessary markup for the current navigation, we can split it into smaller chunks. Node streams allow us to stream data into the response object, which means we can continuously send data down to the client. When the client receives the chunks of data, it can start rendering the contents.

React’s built-in renderToNodeStream allows us to send our application in smaller chunks. As the client can start painting the UI when it’s still receiving data, we can create a very performant first-load experience. Calling the hydrate method on the received DOM nodes will attach the corresponding event handlers, which makes the UI interactive.

Streaming responds well to network backpressure. If the network is clogged and unable to transfer any more bytes, the renderer gets a signal and stops streaming until the network is cleared up. Thus, the server uses less memory and is more responsive to I/O conditions. This enables your Node.js server to render multiple requests simultaneously and prevents heavier requests from blocking lighter requests for a long time. As a result, the site stays responsive even in challenging conditions.

React introduced support for streaming in React 16, released in 2016. It included the following APIs in the ReactDOMServer to support streaming:

ReactDOMServer.renderToNodeStream(element)

The output HTML from this function is the same as ReactDOMServer.renderToString(element) but is in a Node.js ReadableStream format instead of a string. The function will only work on the server to render HTML as a stream. The client receiving this stream can call ReactDOM.hydrate() to hydrate the page and make it interactive.

ReactDOMServer.renderToStaticNodeStream(element)

This corresponds to ReactDOMServer.renderToStaticMarkup(element). The HTML output is the same but in a stream format. You can use it to render static, noninteractive pages on the server and then stream them to the client.

The readable stream output by both functions can emit bytes once you start reading from it. You can achieve this by piping the readable stream to a writable stream, such as the response object. The response object progressively sends chunks of data to the client while waiting for new chunks to be rendered.

Edge SSR

Edge SSR enables you to server-render from all regions of a CDN and experience a near-zero cold boot.

Serverless functions can be used to generate the entire page server-side. The edge runtime also allows HTTP streaming so that you can stream parts of the document as soon as they are ready and hydrate these components granularly. This reduces the time to FCP.

A use case for this pattern is building region-specific listing pages for users. The majority of the page contains only static data; it’s just the listings that require request-based data. Instead of server-rendering the entire page, we can now choose to render only the listing component server-side and the rest edge-side. Whereas we initially had to server-render the whole page to achieve this behavior, we can now get the excellent performance of static rendering on the edge with the dynamic benefits of SSR.

Hybrid Rendering

As the name suggests, hybrid rendering combines different approaches to focus on delivering an optimal result. It represents a mental shift in how developers approach web development, moving from a client-only starting point to a more versatile combination of rendering strategies. Pages that can be served statically will be prerendered. A dynamic strategy may be chosen for other pages in the app (e.g., ISR or SSR or CSR and streaming for subsequent navigations).

Hybrid rendering conceptually challenges traditional terminology (SPA, MPA, SSR, SSG) and emphasizes the need for new verbiage to describe modern web development practices better. A web app need not be classified as an SPA or MPA anymore. It can easily transition from one to the other based on the function served. Thus, it provides the benefits of SPAs (no server needed) while avoiding issues with static rendering (navigation without page reloads).

The shift in focus is not from writing SPAs to not writing SPAs but rather from being locked into SPAs to using whatever rendering mode makes sense for each page, thus entering the hybrid era. This shift is primarily mental, where developers start with build-time and client rendering and add server rendering as needed on a per-page basis.

As the web development landscape converges toward hybrid rendering, we see that many frameworks, both within and outside the React universe, have started supporting it. For example:

  • Next.js 13 combines React Server Components and the Next.js App Router to demonstrate the potential of hybrid rendering.

  • Astro 2.0 brings the best of both static and dynamic rendering instead of choosing between SSG and SSR.

  • Angular Universal 11.1 has native hybrid rendering support. It can perform prerendering (SSG) for static routes and SSR for dynamic routes.

  • Nuxt 3.0 lets you configure route rules for hybrid rendering support.

Progressive Hydration

Progressive hydration implies you can individually hydrate nodes over time so that you request only the minimum necessary JavaScript at any time. By progressively hydrating the application, we can delay the hydration of less critical parts of the page.

This way, we reduce the amount of JavaScript requested to make the page interactive and only hydrate the nodes once the user needs them, for example, when a component is visible in the viewport. Progressive hydration also helps avoid the most common SSR rehydration pitfalls, where a server-rendered DOM tree is destroyed and immediately rebuilt.

The idea behind progressive hydration is to provide excellent performance by activating your app in chunks. Any progressive hydration solution should also consider how it will impact the overall UX. You cannot have chunks of the screen popping up one after the other and blocking any activity or user input on the chunks that have already loaded. Thus, the requirements for a holistic progressive hydration implementation are as follows:

  • Allows usage of SSR for all components

  • Supports splitting of code into individual components or chunks

  • Supports client-side hydration of these chunks in a developer-defined sequence

  • Does not block user input on chunks that are already hydrated

  • Allows usage of some loading indicator for chunks with deferred hydration

React concurrent mode will address all these requirements once it is available. It allows React to work on different tasks simultaneously and switch between them based on the given priority. When switching, a partially rendered tree need not be committed so that the rendering task can continue once React switches back to the same task.

Concurrent mode can be used to implement progressive hydration. In this case, the hydration of each chunk on the page becomes a task for React concurrent mode. If a task of higher priority, like user input, needs to be performed, React will pause the hydration task and switch to accepting the user input. Features like lazy() and Suspense() allow you to use declarative loading states. These can be used to show the loading indicator while chunks are lazy-loaded. SuspenseList() can be used to define the priority for lazy-loading components. Dan Abramov has shared a great demo that shows the concurrent mode in action and implements progressive hydration.

Islands Architecture

Katie Sylor-Miller and Jason Miller popularized the term Islands architecture to describe a paradigm that aims to reduce the volume of JavaScript shipped through “islands” of interactivity that can be independently delivered on top of otherwise static HTML. Islands are a component-based architecture that suggests a compartmentalized page view with static and dynamic islands. Most pages are a combination of static and dynamic content. Usually, a page consists of static content with sprinkles of interactive regions that you can demarcate. The static regions of the page are pure noninteractive HTML and do not need hydration. The dynamic regions are a combination of HTML and scripts capable of rehydrating themselves after rendering.

The Islands architecture facilitates SSR of pages with all of their static content. However, in this case, the rendered HTML will include placeholders for dynamic content. The dynamic content placeholders contain self-contained component widgets. Each widget is similar to an app and combines server-rendered output and JavaScript to hydrate the app on the client.

Islands architecture may be confused with progressive hydration, but there are pretty distinct. In progressive hydration, the hydration architecture of the page is top-down. The page controls the scheduling and hydration of individual components. Each component has its hydration script in the Islands architecture that executes asynchronously, independent of any other script on the page. A performance issue in one component should not affect the other.

Implementing Islands

The Island architecture borrows concepts from different sources and aims to combine them optimally. Template-based static site generators such as Jekyll and Hugo support rendering static components to pages. Most modern JavaScript frameworks also support isomorphic rendering, which allows you to use the same code to render elements on the server and client.

Jason Miller’s post suggests using requestIdleCallback() to implement a scheduling approach for hydrating components. A framework that supports Islands architecture should do the following:

  • Support static rendering of pages on the server with zero JavaScript.

  • Support embedding independent dynamic components via placeholders in static content. Each dynamic component contains its scripts and can hydrate itself using requestIdleCallback() as soon as the main thread is free.

  • Allow isomorphic rendering of components on the server with hydration on the client to recognize the same component at both ends.

The following frameworks support this to some extent at present:

Marko

Marko is an open source framework developed and maintained by eBay to improve server rendering performance. It supports Islands architecture by combining streaming rendering with automatic partial hydration. HTML and other static assets are streamed to the client as soon as they are ready. Automatic partial hydration allows interactive components to hydrate themselves. Hydration code is only shipped for interactive components, which can change state on the browser. It is isomorphic, and the Marko compiler generates optimized code depending on where it will run (client or server).

Astro

Astro is a static site builder that can generate lightweight static HTML pages from UI components built in other frameworks such as React, Preact, Svelte, Vue, and others. Components that need client-side JavaScript are loaded individually with their dependencies. Thus it provides built-in partial hydration. Astro can also lazy-load components depending on when they become visible.

Eleventy + Preact

Markus Oberlehner demonstrates the use of Eleventy (11ty), a static site generator with isomorphic Preact components that can be partially hydrated. It also supports lazy hydration. The component itself declaratively controls its hydration. Interactive components use a WithHydration wrapper so that they are hydrated on the client.

Note that Marko and Eleventy predate the definition of Islands provided by Jason but contain some of the features required to support it. Astro, however, was built based on the definition and inherently supports the Islands architecture.

Pros and Cons

Some of the potential benefits of implementing islands are as follows:

Performance

Reduces the amount of JavaScript code shipped to the client. The code sent consists only of the script required for interactive components. This is considerably less than the script needed to re-create the virtual DOM for the entire page and rehydrate all the elements. The smaller size of JavaScript automatically corresponds to faster page loads.

SEO

Since all the static content is rendered on the server, pages are SEO-friendly.

Prioritization of important content

Key content (especially for blogs, news articles, and product pages) is available almost immediately to the user.

Accessibility

Using standard static HTML links to access other pages helps to improve the accessibility of the website.

Component-based

The design offers all advantages of component-based architecture, such as reusability and maintainability.

Despite the advantages, the concept is still in a nascent stage. The only options for developers to implement Islands are to use one of the few available frameworks or develop the architecture yourself. Migrating existing sites to Astro or Marko would require additional efforts. The architecture is also unsuitable for highly interactive pages like social media apps that would probably require thousands of islands.

React Server Components

React Server Components (RSC) are stateless React components designed to run on the server. They aim to enable modern UX with a server-driven mental model. These zero-bundle-size components facilitate a seamless code transition experience, or “knitting”, between server and client components. This differs from the SSR of components and could result in significantly smaller client-side JavaScript bundles.

RSC uses async/await as the primary way to fetch data from Server Components. They let you incorporate data fetching as an integral part of the component tree, allowing for top-level await and server-side data serialization. Components can thus be refetched regularly. An application with components that re-render when there is new data can be run on the server, limiting how much code needs to be sent to the client. This combines the rich interactivity of client-side apps with the improved performance of traditional server rendering.

RSC protocol enables the server to expose a special endpoint for the client to request parts of the component tree, allowing for SPA-like routing with MPA-like architecture. This allows merging the server component tree with the client-side tree without a loss of state and enables scaling up to more components.

Server Components are not a replacement for SSR. When paired together, they support quickly rendering in an intermediate format, then having SSR infrastructure rendering this into HTML, enabling early paints to still be fast. We SSR the Client Components, which the Server Components emit, similar to how SSR is used with other data-fetching mechanisms.

RSC provides the specification for components. Adoption of RSC depends on frameworks implementing the feature. It is technically possible to use RSC with any React framework, enabling React’s own flavor of partial hydration with an end-state of hybrid rendering. Next.js has already introduced support through its App Router feature. The React team believes RSC will eventually be widely adopted and change the ecosystem.

Hybrid Rendering with RSC and the Next.js App Router

Next.js 13 introduced the App Router with new features, conventions, and support for RSC. Components in the app directory are RSC by default, promoting automatic adoption and improved performance.

RSC provide benefits such as leveraging server infrastructure and keeping large dependencies server-side, leading to better performance and reduced client-side bundle size. The Next.js App Router combines server rendering and client-side interactivity, progressively enhancing the application for a seamless user experience.

Client Components can be added to introduce client-side interactivity, similar to the functionality in Next.js 12 and earlier versions. The “use client” directive can mark components as Client Components. Components without the “use client” directive are automatically rendered as Server Components if not imported by another Client Component.

Server and Client Components can be interleaved in the same component tree, with React handling the merge of both environments. Next.js users have seen performance improvements after adopting RSC and the app directory in production.

Summary

This chapter introduced many patterns that attempt to balance the capabilities of CSR and SSR. Depending on the type of the application or the page type, some of the patterns may be more suitable than others. The chart in Figure 13-1 compares the highlights of different patterns and provides use cases for each.

ljd2 1301
Figure 13-1. Rendering patterns

The following table from Patterns for Building JavaScript Websites in 2022 offers another view pivoted by key application characteristics. It should be helpful for anyone looking for a suitable pattern for common application holotypes.

Portfolio

Content

Storefront

Social network

Immersive

Holotype

Personal blog

CNN

Amazon

Social network

Figma

Interactivity

Minimal

Linked articles

Purchase

Multipoint, real time

Everything

Session depth

Shallow

Shallow

Shallow to medium

Extended

Deep

Values

Simplicity

Discover-ability

Load performance

Dynamicism

Immersiveness

Routing

Server

Server, hybrid

Hybrid, transitional

Transitional, client

Client

Rendering

Static

Static, SSR

Static, SSR

SSR

CSR

Hydration

None

Progressive, partial

Partial, resumable

Any

None (CSR)

Example framework

11ty

Astro, Elder

Marko, Qwik, Hydrogen

Next, Remix

Create React App

We have now discussed some interesting React patterns for components, state management, rendering, and others. Libraries like React do not enforce a specific application structure, but there are recommended best practices for organizing your React projects. Let’s explore this in the next chapter.