February 4, 202312 min read

Rendering strategies: CSR, SSR, SSG, ISR

We have been using frontend libraries/frameworks that help to build single-page applications out of the box where the application is rendered on the client side and we all are pretty familiar with that approach.

There're a few more ways to render applications which we would be covering in this article along with a brief discussion of client-side rendering(CSR), just to see how it's different from pre-rendering.

Client-Side Rendering(CSR):

In this strategy, the server sends a barebones HTML with just an empty <div> tag, and then data fetching, generating the page with the data, routing, etc. are handled by JavaScript(JS) on the client side(browser).

Pros:

  • There’s no round trip to the server to fetch more pages once the initial load is completed, so the user experience becomes really good, almost like a native app experience.

Cons:

  • Not Search Engine Optimization(SEO) friendly as the page is client-side rendered and web crawlers understand server-side rendered pages.
  • Initial page load takes quite some time because most of the web page lifecycle-related tasks are handled by JS which leads to bad First Contentful Paint(FCP), and Time To Interactive(TTI) for the page. Depending on the complexity and size of the app, this increases more. This implies that the user will see almost a blank screen during the time between First Paint(FP) and FCP.

Server-Side Rendering(SSR):

Unlike CSR, in this strategy, the page would be generated on the server side with all data. So, the JS required to render the page won’t be shipped to the client and additional network requests to fetch content to render the page can also be avoided in this way.

In the case of server-rendered pages, the server sends the rendered non-interactive HTML to the client, and then the client downloads the JS bundle to hydrate or make the page interactive by adding event listeners. This process is known as hydration. Read more about hydration and its implementation here.

Steps to generate pages using SSR

Pros:

  • Lesser JS for the client to get parsed leads to better FCP, and TTI and provides an additional budget for client-side JS.
  • SEO enabled as the page is now server-side rendered and crawlers can use the same to index it.

Cons:

  • Slow Time To First Byte(TTFB) since we need to generate the page on the server, users will see a blank page for that time 😟.
  • Full page reload is required for some interactions.

Implementation:

React frameworks like NextJS, Remix, Gatsby, etc. make it easier to implement SSR. We'll use NextJS to show the implementation since it supports all the rendering strategies that we'll be discussing here.

NextJS provides some functions out of the box to implement pre-rendering and getServerSideProps() is one of them to implement server-rendered pages.

import Link from "next/link";

const Home = ({ posts }) => {
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
      }}
    >
      {posts.map((post) => (
        <Link href={`/${post.id}`} key={post.id}>
          <Post {...post} />
        </Link>
      ))}
    </div>
  );
};

export async function getServerSideProps() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts = await response.json();

  return { props: { posts } };
}

export default Home;

getServerSideProps would be executed at run time to fetch some data from external APIs or filesystem and pass that data to the component(in the above example to Home). Finally, the page would be rendered on the server and sent back to the client.

To know which strategy is being used to generate different pages in a nextJS app, we can build the app locally and that would give the following result:

Build log of SSR implementation with NextJS

The build result shows the rendering strategy in use, generated pages during the build with the total time the pages took to be built, and other details for each page. It depicts the rendering strategy using some symbols:

ƛ(lambda for server-side rendered pages),

○(circle for static pages),

●(filled circle for static site generated pages).

In our implementation, we've used SSR to generate the home page and the build log also tells the same using ƛ beside the home(/) route.

Static Site Generation(SSG):

SSG solves all the problems introduced by CSR and SSR by generating pages of an app at build time with all the data. So, in this way, the server won’t have to render the page, and the client would also require minimal JS to make the pages interactive which leads to faster TTFB, FCP, and TTI.

Steps to generate pages using SSG

Pros:

  • Great performance as the page would be built during build time and ready to be served immediately.
  • SEO friendly as the pages are pre-rendered at build time and available on a server.

Cons:

  • Generating a large number of HTML files during build time can be challenging and time-consuming.
  • Any update in data would require the app to go through the build process to generate the pages again with the latest data - which is not that great!
  • Not so good for highly dynamic content.

Implementation:

There're 2 methods - getStaticProps() and getStaticPaths() that can be used to render static pages. So, if we have a page, for example, to show all posts, we can use getStaticProps() only to fetch data and generate the page whereas getStaticPaths() could be used along with getStaticProps to generate a page for an individual post.

import Link from "next/link";

const Home = ({ posts }) => {
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
      }}
    >
      {posts.map((post) => (
        <Link href={`/${post.id}`} key={post.id}>
          <Post {...post} />
        </Link>
      ))}
    </div>
  );
};

export async function getStaticProps() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts = await response.json();

  return { props: { posts } };
}

export default Home;

The above implementation would show all the posts on the home page. The implementation is almost similar to SSR, only the method name is different and page generation would happen during build time.

Now, to generate pages for individual posts, we can use getStaticPaths to fetch all posts and take unique IDs, so that a unique route can be generated for each post with the help of dynamic routes features provided by NextJS.

// generating list of paths/page urls of posts using unique IDs of posts
export async function getStaticPaths() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts = await response.json();

  const paths = posts.map((post) => ({ params: { id: `${post.id}` } }));

  return { paths, fallback: false };
}

// fetching the post details for the given id(ids would come from the result of `getStaticPaths()`) and passing it to the component
export async function getStaticProps({ params }) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${params.id}`
  );
  const post = await response.json();

  return { props: { post } };
}

// rendering the given post details
const PostDetails = ({ post }) => {
  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        height: "100vh",
      }}
    >
      <Post {...post} />
    </div>
  );
};

export default PostDetails;

If you see the getStaticPaths() method in the above snippet, we have used fallback: false that means if a user tries to access a page that is not available then the 404 page would be shown.

This is what the build log of SSG implementation looks like:

Build log of SSG implementation with NextJS

As per our implementation and the build result, we can see that the home page and 100 post details pages are using SSG with build time for each.

Incremental Static Regeneration(ISR):

ISR solves the problems with SSG by generating a subset of pages at build time and the rest of the pages on demand. Also, it provides a mechanism to regenerate the pages again if there’s any update in data.

ISR is only supported by NextJS as of today which provides a property called revalidate (which takes an interval in seconds) to validate the data and trigger a page regeneration.

Does that mean that the pages are regenerated continuously at the given interval?

Nope, it regenerates a page once there's any update in data and the given interval is over after that.

Even after the given interval, the first visitor of the page will still see the page with stale data and the page would be regenerated with the latest data in the background. Once the page is generated successfully, it'll replace the old page. If the page regeneration fails for some reason, the old page remains unaltered.

Steps to generate pages using ISR

Pros:

  • This way of rendering pages comes with almost all the benefits of SSG and it reduces the build time of an application as it avoids pre-rendering all the pages during build time.
  • Regenerates pages if there’s any update in data without rebuilding the whole application.

Cons:

  • When users visit a page, we want them to see the most up-to-date version immediately. With ISR the first visitor to a page will not see that. They will always see a fallback first or a page with old data. And then later, if the data gets stale, the first visitor to see that cached page will see the out-of-date data first before it revalidates.

Implementation:

We use the getStaticProps method only to implement ISR but use an extra property revalidate while passing the data to the component.

So, the implementation with ISR for the home page would change as follows:

import Link from "next/link";

const Home = ({ posts }) => {
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
      }}
    >
      {posts.map((post) => (
        <Link href={`/${post.id}`} key={post.id}>
          <Post {...post} />
        </Link>
      ))}
    </div>
  );
};

export async function getStaticProps() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts = await response.json();

  return {
    props: {
      posts,
    },
    revalidate: 10,
  };
}

export default Home;

And the implementation with ISR for the individual posts page would change as follows:

// ISR - fallback: 'blocking'. Restrict in `getStaticPaths()` to generate only limited number of pages and generate rest of the pages on demand
export async function getStaticPaths() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts = await response.json();

  const paths = posts
    .slice(0, 15)
    .map((post) => ({ params: { id: `${post.id}` } }));

  return { paths, fallback: "blocking" };
}

export async function getStaticProps({ params }) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${params.id}`
  );
  const post = await response.json();

  return { props: { post }, revalidate: 10 };
}

// ISR - fallback: 'blocking'
const PostDetails = ({ post }) => {
  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        height: "100vh",
      }}
    >
      <Post {...post} />
    </div>
  );
};

export default PostDetails;

If you've noticed the code for the post details page, we've used blocking as the value of fallback , which means the rest of the pages would be generated server-side on demand. The other possible value of fallback is true and in that case, we can show the fallback page while the page is getting generated.

import { useRouter } from "next/router";

// ISR - fallback: true.
export async function getStaticPaths() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts = await response.json();

  const paths = posts
    .slice(0, 15)
    .map((post) => ({ params: { id: `${post.id}` } }));

  return { paths, fallback: true };
}

export async function getStaticProps({ params }) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${params.id}`
  );
  const post = await response.json();

  return { props: { post }, revalidate: 10 };
}

// ISR - fallback: true, show fallback page if value of fallback is true
const PostDetails = ({ post }) => {
  const router = useRouter();

  if (router.isFallback) {
    return <Loader />;
  }

  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        height: "100vh",
      }}
    >
      <Post {...post} />
    </div>
  );
};

export default PostDetails;

Now, let's see how the build process of ISR is different from the build of an application using SSG.

Build log of ISR implementation with NextJS

As per the logs above, we can see that we're generating 15 pages for individual posts instead of all individual posts during build time which reduces the build time significantly and we're revalidating any updates in data at an interval of 10 seconds.

That's all for now 😃. Thanks for reading till now 🙏.

If you want to read more about these strategies, refer to Rendering Introduction.

Share this blog with your network if you found it useful and feel free to comment if you've any doubts about the topic.

You can connect 👋 with me on GitHub, Twitter, and LinkedIn.