Rendering is an act of generating and displaying content on a web page which the user can interact with. React offers a variety of ways of rendering content, and these rendering patterns allow us to tune web vitals.
We’ll go over client side rendering, server side rendering, static rendering, incremental static rendering, hydration, partial hydration, streaming SSR, islands, and server components.
These patterns aren’t silver bullets and should be used based on various metrics to make your users experience better. Like, if we use Server Side Rendering to get increase the initial load time but it also increase time for interactivity (which will make the site look broken), then that’s a bad user experience. We’ve to look at metrics to see which pattern will make users life better.
The decision on where and how to fetch and render content is key to the performance of an application.
Client Side Rendering
CSR is the default rendering pattern in React. As the name suggest, all of the rendering happens in the browser after the initial load.
Whenever the application is requested the server sends a shell HTML file with JavaScript to the client. This JavaScript is then executed on the browser to render content. In order to fetch any required data we’ve to make additional HTTP request.
Single page applications can have multiple routes, but these routes don’t point to the server. Instead, they’re updated in the browser via JavaScript.
The advantage of this pattern is that it gives a near native experience on the browser which wasn’t possible with multi page applications. This also removes the need for a server (thus saving cost) as we can just create a static build and serve the content.
There’re some major disadvantages too, like it requires a huge JavaScript bundle which can make the initial load slow, and since in the initial load it gets only shell HTML, SEO takes a hit as crawlers have a hard time understanding pages content (same is true for dynamic routes).
Server Side Rendering w/ hydration
At the dawn of Web 2.0 we had multi page applications (MPA). Here the page request is sent to the server. The server gets the required data, generates HTML page dynamically, and sends its to the browser which then shows that page.
In this, whenever the route changed, a new HTML page is rendered on the server (100% on the server) and is returned to the browser.
Some of the large websites still uses MPA, and there’re popular frameworks to build such apps like Django, Express, etc…
When this pattern is combined with hydration we get modern SSR.
In SSR, page request is sent to the server where the server can fetch data and generate the HTML page which then will be sent as the response to the browser. After the initial load JavaScript takes over and attaches all of the handlers and listeners. This process is called hydration. This is what gives Single Page Application experience.
You can use frameworks like NextJS (page route) to use SSR out of the box.
// src/pages/ssr.tsx
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
// serverless or edge runtime
export const config = {
runtime: "nodejs", // or "edge"
};
type Props = { username: string };
export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
if (!ctx.query.username) {
return {
notFound: true,
};
}
return {
props: { username: ctx.query.username.toString() },
};
};
export default function SSRExample(
props: InferGetServerSidePropsType<typeof getServerSideProps>
) {
return (
<main>
<h1>SSR</h1>
<p>{props.username}</p>
</main>
);
}
> next build
▲ Next.js 14.2.4
✓ Linting and checking validity of types
Creating an optimized production build ...
✓ Compiled successfully
✓ Collecting page data
✓ Generating static pages (3/3)
✓ Collecting build traces
✓ Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 277 B 78.5 kB
├ /_app 0 B 78.2 kB
├ ○ /404 180 B 78.4 kB
└ ƒ /ssr 323 B 78.6 kB
+ First Load JS shared by all 78.3 kB
├ chunks/framework-a85322f027b40e20.js 45.2 kB
├ chunks/main-3362964851ad4fb5.js 32 kB
└ other shared chunks (total) 1.07 kB
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demandbas
You can learn more about how this works and can be implemented using React w/ Vite in the following article.
By getting HTML page directly from the server we get great benefits like reduction in JavaScript as we’re not building the page which in turn reduces the initial load time and also gives quicker initial interactivity. Also, search engines can index the content of the pages more effectively since the content is already present in the HTML when the page loads.
It requires a serve and servers cost. Server has to render all of the React components and combined this with fetching data, a high traffic page would take time and hence giving user a bad experience. You won’t have access to DOM things (like window
or document
) since we’re on server.
Static Site Generation w/ hydration
In the beginning of time, Web 1.0, the content on web was static. This is the simplest form of rendering. Here we generate all of the web pages, upload them as static files in the internet, and serve them.
We can create static websites using Hugo, 11ty, and jekyll.
When this is combined with hydration we can get a cost effective site that behaves like a SPA. In this pattern we render all of the web pages in advance and upload it to a static host (like a storage bucket). When a user requests a page, it’ll get the page from static host and after the initial load it’ll perform hydration i.e. attaching event listeners, handlers, etc.
Websites built using SSG w/ hydration are called as JAMStack sites.
The benefits of this pattern are fast initial load, great SEO, SPA like interactivity, and low costing. Also, we can cache all of the pages permanently on a CDN making it extremely fast. You can use frameworks like NextJS (page route) to use SSG out of the box.
// src/pages/ssg/[userId].tsx
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
const data = [
{ userId: 1, username: "John Doe", initialCount: 0 },
{ userId: 2, username: "Jane Doe", initialCount: 5 },
{ userId: 3, username: "Jack Doe", initialCount: 2 },
];
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: data.map((user) => ({
params: { userId: user.userId.toString() },
})),
// { fallback: false } means other routes should 404.
fallback: false,
};
};
type Props = {
user: (typeof data)[number];
};
export const getStaticProps: GetStaticProps<Props> = async (ctx) => {
if (!ctx.params?.userId) {
return {
notFound: true,
};
}
const user = data.find(
(user) => user.userId === Number(ctx.params!.userId)
);
if (!user) {
return {
notFound: true,
};
}
return {
props: { user },
};
};
export default function SSGExample(
props: InferGetStaticPropsType<typeof getStaticProps>
) {
const [count, setCount] = useState(props.user.initialCount);
const router = useRouter();
// if fallback is true
if (router.isFallback) {
return <div>Loading...</div>;
}
return (
<main>
<h1>SSG</h1>
<p>
{props.user.username} {count}
</p>
<button onClick={() => setCount(count + 1)}>⬆️ Increment</button>
</main>
);
}
> next build
▲ Next.js 14.2.4
✓ Linting and checking validity of types
Creating an optimized production build ...
✓ Compiled successfully
✓ Collecting page data
✓ Generating static pages (7/7)
✓ Collecting build traces
✓ Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 277 B 78.5 kB
├ /_app 0 B 78.2 kB
├ ○ /404 180 B 78.4 kB
├ ○ /csr 355 B 78.6 kB
├ ● /ssg/[userId] (339 ms) 462 B 78.7 kB
├ ├ /ssg/1
├ ├ /ssg/2
├ └ /ssg/3
└ ƒ /ssr 323 B 78.6 kB
+ First Load JS shared by all 78.3 kB
├ chunks/framework-a85322f027b40e20.js 45.2 kB
├ chunks/main-3362964851ad4fb5.js 32 kB
└ other shared chunks (total) 1.07 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
ƒ (Dynamic) server-rendered on demand
The downside is that every time content changes, we’ve to re-build the pages and deploy them which is not suitable for large application where content changes are often made.
This issue is solved via Incremental Static Regeneration.
Incremental Static Regeneration
In ISR, we initially deploy our site with all of the web page like in SSG but we’ll rebuild an individual page on the server when its cache is invalidated and then upsert that page.
This helps us to manage dynamic data and still have benefits of static site. For this we won’t require a server deployment like SSR. It sits between SSG and SSR. The only drawback is that it hard to implement by ourself or self-host, and we would need services like Vercel to deploy our site.
// src/pages/isr/[userId].tsx
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
const data = [
{ userId: 1, username: "John Doe", initialCount: 0 },
{ userId: 2, username: "Jane Doe", initialCount: 5 },
{ userId: 3, username: "Jack Doe", initialCount: 2 },
];
const newData = [
{ userId: 4, username: "Robin", initialCount: 0 },
{ userId: 5, username: "Ron", initialCount: 5 },
{ userId: 6, username: "Rolly", initialCount: 2 },
];
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: data.map((user) => ({
params: { userId: user.userId.toString() },
})),
fallback: "blocking",
};
};
type Props = {
user: (typeof data)[number];
};
export const getStaticProps: GetStaticProps<Props> = async (ctx) => {
if (!ctx.params?.userId) {
return {
notFound: true,
};
}
let user = data.find((user) => user.userId === Number(ctx.params!.userId));
if (!user) {
user = newData.find(
(user) => user.userId === Number(ctx.params!.userId)
);
if (!user) {
return {
notFound: true,
};
}
}
return {
props: { user },
revalidate: 10,
};
};
export default function ISRExample(
props: InferGetStaticPropsType<typeof getStaticProps>
) {
const [count, setCount] = useState(props.user.initialCount);
const router = useRouter();
// if fallback is true
if (router.isFallback) {
return <div>Loading...</div>;
}
return (
<main>
<h1>ISR</h1>
<p>
{props.user.username} {count}
</p>
<button onClick={() => setCount(count + 1)}>⬆️ Increment</button>
</main>
);
}
> next build
▲ Next.js 14.2.4
✓ Linting and checking validity of types
Creating an optimized production build ...
✓ Compiled successfully
✓ Collecting page data
✓ Generating static pages (10/10)
✓ Collecting build traces
✓ Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 277 B 78.5 kB
├ /_app 0 B 78.2 kB
├ ○ /404 180 B 78.4 kB
├ ○ /csr 355 B 78.6 kB
├ ● /isr/[userId] (ISR: 10 Seconds) 462 B 78.7 kB
├ ├ /isr/1
├ ├ /isr/2
├ └ /isr/3
├ ● /ssg/[userId] 462 B 78.7 kB
├ ├ /ssg/1
├ ├ /ssg/2
├ └ /ssg/3
└ ƒ /ssr 323 B 78.6 kB
+ First Load JS shared by all 78.3 kB
├ chunks/framework-a85322f027b40e20.js 45.2 kB
├ chunks/main-3362964851ad4fb5.js 32 kB
└ other shared chunks (total) 1.07 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
(ISR) incremental static regeneration (uses revalidate in getStaticProps)
ƒ (Dynamic) server-rendered on demand
Partial Hydration
In case of SSG and SSR, the HTML is sent to the client and then hydration takes place. While this hydration is happening, user can’t interact with the application. In large applications where we’ve tons of things, this hydration take longer time.
Hydration is the process of attaching the React components to the already-present HTML is called hydration. During hydration, React attaches event listeners and other dynamic behavior to the static HTML elements.
We can avoid this by hydrating some content (like buttons) immediately and hydrating other parts (like modal) only when the user starts interacting with them. This is called partial hydration.
We can lazily load these component using lazy
and Suspense
, and build tools like Vite and Webpack will do code splitting and will lazily load the JavaScript required to those components.
// src/pages/tabs.tsx
import { useState, lazy, Suspense } from "react";
import { FirstTab } from "@app/components/FirstTab";
const SecondTab = lazy(async () => {
return import("@app/components/SecondTab").then((mod) => ({
default: mod.SecondTab,
}));
});
export default function Tabs() {
const [tab, setTab] = useState(0);
return (
<main>
<h1>Tabs</h1>
<div>
<button onClick={() => setTab(0)}>Tab 1</button>
<button onClick={() => setTab(1)}>Tab 2</button>
</div>
{tab === 0 ? (
<FirstTab />
) : (
<Suspense fallback={null}>
<SecondTab />
</Suspense>
)}
</main>
);
}
// src/components/FirtTab.tsx
export function FirstTab() {
return <div>First tab</div>;
}
// src/components/SecondTab.tsx
export function SecondTab() {
return <div>Second tab</div>;
}
> next build
▲ Next.js 14.2.4
✓ Linting and checking validity of types
Creating an optimized production build ...
✓ Compiled successfully
✓ Collecting page data
✓ Generating static pages (11/11)
✓ Collecting build traces
✓ Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 277 B 79.3 kB
├ /_app 0 B 79 kB
├ ○ /404 180 B 79.2 kB
├ ○ /csr 355 B 79.3 kB
├ ● /isr/[userId] (ISR: 10 Seconds) 462 B 79.5 kB
├ ├ /isr/1
├ ├ /isr/2
├ └ /isr/3
├ ● /ssg/[userId] 462 B 79.5 kB
├ ├ /ssg/1
├ ├ /ssg/2
├ └ /ssg/3
├ ƒ /ssr 323 B 79.3 kB
└ ○ /tabs 449 B 79.4 kB
+ First Load JS shared by all 79 kB
├ chunks/framework-a85322f027b40e20.js 45.2 kB
├ chunks/main-3362964851ad4fb5.js 32 kB
└ other shared chunks (total) 1.82 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
(ISR) incremental static regeneration (uses revalidate in getStaticProps)
ƒ (Dynamic) server-rendered on demand
If you want to learn about code splitting with React and Vite then read the following article.
Other Rendering Patterns
Along side these rendering patterns we’ve other patterns like Island (employed by Astro and Remix), and Streaming SSR (NextJS app router).
In Island rendering pattern, instead of having JavaScript perform hydration over the entire app, we perform hydration only in places (islands) of interactivity, and no JavaScript is loaded for static content.
In Streaming SSR rendering pattern we can load server side content concurrently in multiple chunks instead of loading it all at once. This makes UI interactive much quicker and is much more performant. This is possible due to low building blocks like Server Components.