21st June 2021 8 min read

Using Next.js and Vercel to instantly load a data-heavy website

Seif Ghezala

A React application is JavaScript code that gets transformed into static HTML. This transformation is called "rendering".

Whenever you build a React application, you're inevitably making a decision on when to render it and you usually have 3 choices:

  • Client-side rendering: the application is rendered on the browser when the script loads.
  • Server-side rendering: the application is rendered on the server at every page request.
  • Static site generation: the application is rendered on the cloud provider (e.g. AWS, Vercel, Netlify) at every deployment.

A while ago, we faced this scenario when building illuminem, an energy news aggregator that showcases thousands of posts daily.

In this article, we'll talk about the performance problems we faced and how we ended up leveraging Next.js and Vercel to solve them.

illuminem's architecture consists of a service that crawls RSS feeds and web pages for energy-related posts, categorizes them, and pushes them to a headless CMS called Sanity.

On the CMS, content managers create collections of these posts based on filters like "category".

For example, they can create a collection called "Renewables" and use the "category" filter to only include posts that match the "renewables" category:

Renewables category created from the CMS

The frontend is a Next.js application that fetches these collections and displays them as carousels.

illuminem's architecture

Building a product is not easy because requirements change throughout the process, so we played it safe to make sure we can be flexible enough to handle these changes and reach the finish line ahead of time.

We were not sure how often we'd get new posts from the crawler, so we rendered most of our pages server-side.

We used getServerSideProps to fetch pages data from the CMS at every request.

Here's a simplified example from the homepage:

jsx
1export default function HomePageContainer({ data }) {2  return (3    <Layout>4      <HomePage data={data} />5    </Layout>6  );7}8
9// Called on the server after each request10export async function getServerSideProps() {11  try {12    const data = await fetchHomeDataFromCMS();13
14    return {15      props: { data },16    };17  } catch (error) {18    console.error("Error fetching homepage data", error);19  }20}

By the time we were done, the crawler had been running for 2 months and we started to feel the heavy page load.

Even after limiting the number of posts per collection, each carousel could have hundreds of posts and most of our pages had dozens of carousels, so we're talking about thousands of posts per page.

On average, it took 5 seconds to load a page on a very good WiFi connection.

It was no surprise that our TTFB (Time to First Byte) was heavily impacted since every time a user visits a page:

  • The server had to make a request with a huge query to the CMS.
  • The CMS had to parse that query and form the response data.
  • Once the server received a response from the CMS with thousands of posts, it had to render the React application before sending it to the browser.

Some of the pages were not making any requests in getServerSideProps to get data before rendering. Next.js made these pages static by default.

But what if a page needs to fetch data before building?

Well, Next.js provides a getStaticProps that allows to fetch the data and render the page at build time. This would create static pages that load instantly.

jsx
1export default function HomePageContainer({ data }) {2  return (3    <Layout>4      <HomePage data={data} />5    </Layout>6  );7}8
9// Called at build time10export async function getStaticProps() {11  try {12    const data = await fetchHomeDataFromCMS();13
14    return {15      props: { data },16    };17  } catch (error) {18    console.error("Error fetching homepage data", error);19  }20}

Unfortunately, most of the other pages could not be completely static. In fact, most of them have a "Most Trending" carousel to display the most viewed posts in the past 48 hours, so it had to be up-to-date with the actual views metrics.

If we fetch the data at build time, the "Most Trending" carousel wouldn't be updated until the next build.

Most Trending carousel

At this point, we wondered: why not make these pages render client-side?

The server wouldn't have to make any heavy work querying data and rendering the page.

Instead, each carousel can make a request to fetch its collection of data and then render it.

The main advantage would be that the TTFB would drastically decrease, making the page reach the browser pretty fast.

However, knowing that each page has on average 12-15 carousels, that would result in 12-15 queries per page visit. Our CMS payment plan is based on the number of queries we make, so this would make us reach the limit in no time and would certainly blow up when illuminem picks up more users.

On top of that, what we gain in performance in the server is lost in the client. The page would reach the browser fast, but it will be mostly a bunch of spinners. Each carousel would yet have to make a request to get its data and then render it.

Because of these two reasons, client-side rendering was out of the table.

Note: if you prefer video format, here's a video explaining this section.

Next.js introduced incremental static regeneration in the 9.5 version release, making it possible to generate static pages at run-time.

We can now generate static pages at build time, which makes them load instantly.

But, how can we keep the "Most Trending" carousel content up-to-date?

Every time a user visits one of these pages, getStaticProps is run by the Next.js server in the background.

When the result of getStaticProps is different from the previous run because the CMS data changed, the stale page is replaced by an updated one.

The updated page is generated at run-time without affecting the user experience.

The best part is that we only had to set the revalidate property to 3600 to revalidate the page every hour.

jsx
1export default function HomePageContainer({ data }) {2  return (3    <Layout>4      <HomePage data={data} />5    </Layout>6  );7}8
9// Called at build and run-time10export async function getStaticProps() {11  try {12    const data = await fetchHomeDataFromCMS();13
14    return {15      props: { data },16      // Revalidates the page every hour17      revalidate: 60 * 60,18    };19  } catch (error) {20    console.error("Error fetching homepage data", error);21  }22}

For pages that depend on a route parameter (e.g. /[category]), we were able to generate a static page for each possible parameter by using the getStaticPaths method:

jsx
1import categories from "../categories";2
3export default function CategoryPageContainer({ data }) {4  return (5    <Layout>6      <CategoryPage data={data} />7    </Layout>8  );9}10
11export async function getStaticProps({ params: { category } }) {12  try {13    const data = await fetchCategoryDataFromCMS(category);14
15    return {16      props: { data },17      revalidate: 1,18    };19  } catch (error) {20    console.error("Error fetching homepage data", error);21  }22}23
24export async function getStaticPaths() {25  const categories = await fetchCategoriesFromCMS();26
27  return {28    paths: categories.map((category) => ({29      params: { category },30    })),31  };32}

Users can click on a post to see its details in a modal and share it on social media.

Modal to share social media

Each post modal has a URL and we could add the meta-data tags required to show a card preview snippet on the social media platforms.

Unfortunately, when such URLs are shared, social media platforms could not get the right meta-data tags since they are only added once the modal appears in the client.

To fix that, we generated at run-time a static page for each post.

Such pages only have the post modal rendered statically with the right meta-data. The rest of the page is rendered client-side.

We then used the URLs of these pages when sharing on social media.

jsx
1export default function PostPage({ postData }) {2  const [homeData, setHomeData] = React.useState({});3
4  React.useEffect(() => {5    fetchHomeDataFromCMS().then(setHomeData);6  }, []);7
8  return (9    <>10      <Layout>{!homeData ? null : <HomePage data={homeData} />}</Layout>11      <PostModal data={postData} />12    </>13  );14}15
16export async function getStaticProps({ params: { postId } }) {17  const postData = await fetchPostDataFromCMS(postId);18
19  try {20    return {21      props: { postData },22      revalidate: 60 * 60,23    };24  } catch (error) {25    console.error("Error fetching post data", error);26
27    // Fallback to 404 page in case of error28    return { notFound: true };29  }30}31
32// Nothing is generated at build time33export async function getStaticPaths() {34  return {35    paths: [],36    fallback: "blocking",37  };38}

We set fallback to blocking in getStaticPaths to only return the page once it has finished loading. You can read more about the other fallback possibilities Next.js offers here.

The first request to such pages might be a bit slow, but all the following requests resolve immediately because their static version was already generated.

Social media platforms display now a proper snippet of the shared post because its required meta-data tags are available immediately in the HTML response.

Social sharing

Social sharing snippet