Data Fetching Strategies in Next.js and Their Impact on Page Rendering and Performance

Ashwani Kumar Jha's avatar

Ashwani Kumar Jha

When building an app with Next.js, choosing the right data fetching strategy is key to optimizing our app's speed, user experience, and server efficiency. Next.js 14 has a range of options for fetching data, each with its own impact on how pages are rendered and how well the app performs. In this guide, we'll walk through these various strategies, understanding their benefits and trade-offs.

In this blog post, we will take a hypothetical scenario of building a blog post app. As we progress, we'll encounter different scenarios requiring unique data fetching approaches. By exploring these strategies step-by-step within the context of our hypothetical blog app, we'll discover the most suitable options for different needs.

📌 You're likely aware, but just as a reminder: to truly understand the impact of these data fetching strategies, it's important to evaluate them in a production environment. In development mode, pages are rendered on each request to facilitate the development process.

Are you ready? Let's go!

Starting with Static Generation

As our mission is to launch a blog app, what's our first move? We can begin by creating a page for each blog post.

// app/blog/post-1/page.jsx
 
export default function BlogPost1() {
  return (
    <>
      <h1>Blog Post - 1</h1>
      <p>This is the first blog post.</p>
    </>
  );
}

With every new page, we build the project which statically generates the blog pages. This results in a fully static scenario where the pre-built (pre-rendered) page is delivered from the server to the user upon each request.

This method offers a fast response and lowers server usage, but it's not the most efficient when our blog post numbers start to grow. We need a better way to manage this.

Transitioning to Dynamic Routes

As our content expands, we recognize the inefficiency of creating a new page for every blog post, especially when they all share a similar structure. It's time to think about using markdown files for our blog content and leveraging Next.js dynamic routes. This approach means that to add a new blog post, we just need to create the markdown file.

Here's a simplified example of what a markdown file for a blog post might look like:

// content/blogs/post-1.md
 
---
title: 'Blog Post 1'
---
 
This is the content of blog post 1.

Here's how we set up the mechanism to read and transform these markdown files into blog post page data:

// lib/blogs.js
 
import { readFile } from 'node:fs/promises';
import matter from 'gray-matter';
import { marked } from 'marked';
 
export async function getBlog(slug) {
  const text = await readFile(`./content/blogs/${slug}.md`, 'utf8');
  const {
    content,
    data: { title },
  } = matter(text);
  const body = marked(content);
 
  return { title, body };
}

And to render these markdown files as blog pages, our dynamic route handler will look like this:

// app/blog/[slug]/page.jsx
 
import { getBlog } from '@/lib/blogs';
 
export default async function BlogPage({ params: { slug } }) {
  const review = await getBlog(slug);
 
  return (
    <>
      <h1>{review.title}</h1>
      <article dangerouslySetInnerHTML={{ __html: review.body }} />
    </>
  );
}

With this setup, we transition from a fully static site to one where blog posts are dynamically generated. Each markdown file represents a new post, allowing us to maintain a consistent look and feel across our blog without redundant code.

This change, however, means our pages are now rendered server-side at runtime. We've stepped away from the static generation's advantages of pre-rendered HTML and less server load. Server-side rendering requires a Node server to dynamically generate the HTML for each request.

But here's a question: what if we could have the best of both worlds? What if we could statically generate pages for our dynamic routes? Well, it turns out, with Next.js, we can.

Server-Side Rendering for Dynamic Content

By using generateStaticParams, we can tell Next.js exactly which blog post pages to generate at build time.

Here's an example of how this might look:

// app/blog/[slug]/page.jsx
 
import { getBlog } from '@/lib/blogs';
 
export async function generateStaticParams() {
  // Ideally, we will fetch slugs programmatically here
  return [{ slug: 'post-1' }, { slug: 'post-2' }];
}
 
export default async function BlogPage({ params: { slug } }) {
  const review = await getBlog(slug);
 
  return (
    <>
      <h1>{review.title}</h1>
      <article dangerouslySetInnerHTML={{ __html: review.body }} />
    </>
  );
}

This setup works well for our team, with generateStaticParams, we've reclaimed the advantages of static generation.

But what if our blog grows, attracting more contributors? Some may not be comfortable with markdown or coding. So, we need a way for diverse contributors to join in easily.

This is where a CMS (Content Management System) becomes invaluable. By migrating our content management to a CMS, we can allow contributors to write and submit posts without needing to interact with the codebase or markdown files.

Our implementation shifts from reading markdown files to fetching content from the CMS, but the end result remains the same: our pages are still pre-built during the build phase, with no need for server-side rendering at runtime.

The core change lies in our data fetching methods; instead of pulling content from markdown, we retrieve it directly from the CMS.

But what happens when a new blog post is added to the CMS?

On-demand Page Generation

Ideally, we want any changes in the CMS to be immediately reflected on our blog app. However, with static generation, our site would continue to display the old content until we rebuild and redeploy the application.

Here's where Next.js steps in to bridge the gap with its on-demand page generation. If a user navigates to a newly added blog post that doesn't have a pre-built page, Next.js can generate this page on the fly, at the time of the request. Once generated, this page becomes part of the static pages, and subsequent requests will be served this pre-built page, just as if it had been generated at build time.

This on-demand behavior can be controlled using the dynamicParams option in Next.js 14. By default, this property is set to true, allowing Next.js to generate pages for new paths that don't exist yet. If we set dynamicParams to false, Next.js will not generate new pages on demand; instead, it will return a 404 error for any paths not generated at build time.

Here's how we might configure this in our code:

// app/blog/[slug]/page.jsx
 
import { getBlog } from '@/lib/blogs';
 
export const dynamicParams = false;
 
export async function generateStaticParams() {
  // ...
}
 
export default async function BlogPage({ params: { slug } }) {
  const review = await getBlog(slug);
 
  return (
	//...
  );
}

Dynamic Rendering

But what if content within an existing blog post is updated in the CMS?

The static nature of our pages means the new content won't automatically display. They remain as they were at the time of generation unless we trigger a rebuild.

To address this, Next.js offers a dynamic option that allows us to specify how a route should behave in terms of regeneration.

The default setting of auto lets Next.js cache pages and only regenerate them when it thinks necessary. If we want to ensure that the most up-to-date content is always displayed, we can switch this setting to force-dynamic. This forces a regeneration of the page with every request, ensuring the latest content is always shown.

// app/blog/[slug]/page.jsx
 
import { getBlog } from '@/lib/blogs';
 
export const dynamic = 'force-dynamic';
 
export default async function BlogPage({ params: { slug } }) {
  const review = await getBlog(slug);
 
  return (
	//...
  );
}

With dynamic set to force-dynamic, we no longer need to specify which slugs should be statically generated. Our pages will now be dynamically rendered server-side for each request. This ensures that any changes made to the content in the CMS are immediately visible to users.

However, this approach means that every visit to a blog post triggers a server-side render, which can be slower than serving static files and may increase the load on our server. It's a trade-off between ensuring the most current content and the efficiency of static generation.

Here's the conundrum: we want our readers to always see the most current content without compromising on load times or server performance. Is there a middle ground?

Thankfully, Next.js provides a solution called revalidation.

Time-based Revalidation

Revalidation allows us to enjoy the benefits of static generation with periodic updates in the background. We can configure this at the route level using the revalidate option, which accepts a time interval in seconds.

export const revalidate = 60

Revalidate value is number of seconds.

With the revalidate property set to 60, we inform Next.js that each generated page should be considered fresh for 60 seconds. If a request comes in and the page is older than 60 seconds, Next.js will re-render it in the background. This means the page is served quickly from the cache and then updated in the background, ensuring that subsequent visitors will see the latest version.

So the first request which triggered the revalidation still receives the old page and the consequent request will start seeing the new page.

However, it's important to note that this solution is not without its trade-offs:

  • The page will only update after the specified interval, not immediately when data changes in the CMS.

  • The page might still be re-generated even if the content has not changed, potentially using server resources unnecessarily.

Despite these nuances, time-based revalidation offers a significant leap towards solving our initial conundrum. But can we push this further?

Indeed, We have On-demand revalidation on our disposal.

This approach gives us explicit control over the update mechanism, allowing for instantaneous content refreshes in response to specific events.

On-demand Revalidation

On-demand revalidation works by reacting to specific triggers, such as updates in a CMS. We can leverage the modern CMS's webhook support. By exposing an API route as a webhook endpoint, our application can start receiving notifications of content changes. Upon receiving such a notification, we can selectively revalidate pages or data fetches associated with the updated content.

So this approach require our application to somehow know that the data has changed.

This can be done by exposing the API route handler, all the modern CMS provides the webhook support so we need to expose an api which can be used by the CMS to notify us about the data change and based on the data change we can revalidate (regenerate) the page.

Here’s how to set up the content fetching with tag-based revalidation using the revalidateTag function from next/cache module to revalidate all the requests taged with the particular key.

// Tagging our blog fetch requests for selective revalidation
export const CACHE_TAG_BLOGS = 'blogs';
 
export async function getBlog(slug) {
  const response = await fetch(url, {
    next: {
      tags: [CACHE_TAG_BLOGS],
    },
  });
  // ...
  return { title, body };
}

And for capturing and responding to CMS notifications:

// app/webhooks/cms-event/route.js
 
import { revalidateTag } from 'next/cache';
import { CACHE_TAG_BLOGS } from '@/lib/reviews';
 
export async function POST(request) {
  const payload = await request.json();
  // Implement logic to determine if revalidation is necessary
  if (true) {
    revalidateTag(CACHE_TAG_BLOGS);
  }
  return new Response(null, { status: 204 });
}

With this configuration in place, any content updates flagged by our CMS will trigger a selective revalidation for content associated with the specified tags. This approach guarantees that subsequent requests will deliver the latest content, effectively overcoming the challenges posed by time-based revalidation. It enables instant page updates in direct response to changes within the CMS.

This mechanism works by invalidating any cached data associated with the tagged fetch requests. Therefore, if a page request is made and the cache is marked as invalid, Next.js will render the page in the background. The freshly updated page is then served for any subsequent requests, ensuring users always have access to the most current information.

This concludes our exploration of server-side data fetching and its impact on page rendering and performance within a Next.js environment.

Client Side Fetching

Moving forward, let's say that we want to add search functionality to enable users to search for blog posts. For this purpose, we can use the client-side data fetching, a method we're familiar with from traditional React applications.

To enable this functionality, we can utilize third-party libraries like React Query or SWR, which are designed to enhance the client-side data fetching experience. These libraries comes with features like memoizing requests, caching data, revalidating, and mutating data.

Let's wrap up our journey here, and thanks for coming along! In this blog post, we've explored the wide array of data fetching tools that Next.js offers and their impact on page rendering and performance within a Next.js environment.

For more details, it's best to check out the official documentation. That's where we gathered insights for this blog post.