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?
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 it takes for a client to receive the first byte of page content
Time it takes the browser to render the first piece of content after navigation
Time from when the page starts loading to when it responds quickly to user input
Time it takes to load and render the page’s main content
Measures visual stability to avoid unexpected layout shift
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:
The project should build fast for quick iteration and deployment.
The website should limit and optimize the server execution time to reduce execution costs.
The page should be able to load dynamic content performantly.
You can quickly revert to a previous build version and deploy it.
Users should always be able to visit your website through operational servers.
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.
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
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.
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:
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.
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.
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.
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:
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.
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.
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.
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:
Best for pages that do not contain dynamic data
Best for pages where data should refresh on every page load and which have stable placeholder components
Best for pages that should be regenerated on a certain interval or on-demand
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.
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 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.
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 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.
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.
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 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 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.
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.
Some of the potential benefits of implementing islands are as follows:
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.
Since all the static content is rendered on the server, pages are SEO-friendly.
Key content (especially for blogs, news articles, and product pages) is available almost immediately to the user.
Using standard static HTML links to access other pages helps to improve the accessibility of the website.
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 (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.
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.
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.
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.