Enhancing Frontend Performance with Next.js: A New Approach

Posted on October 27, 2024 By

Learn about caching in Next.js App Router.

Frontend performance can be tricky, and one of the biggest challenges in highly optimized applications is the client-server waterfall effect. With the introduction of Next.js App Router, a key focus was on addressing this issue by moving client-server REST fetches to the server. This was achieved using React Server Components, which allows for a single roundtrip, but sometimes at the cost of sacrificing the excellent initial loading performance typical of Jamstack.

To balance these trade-offs, partial prerendering was developed, providing the benefits of both worlds. However, during this process, the developer experience faced some challenges. The caching defaults were adjusted to prioritize performance, but this often hindered rapid prototyping and dynamic applications. Developers found it difficult to manage local database access without relying solely on fetch(). Although unstable_cache() was introduced, it wasn’t very user-friendly. This led to the creation of segment-level configurations like export const dynamic, runtime, fetchCache, dynamicParams, and revalidate to provide more control.

In light of these complexities, there’s an exciting new experimental mode in the NextJS 15 works that focuses on simplifying the developer experience using just two core concepts: <Suspense> and the use of cache. This approach aims to streamline development while maintaining performance, offering a more intuitive solution for building dynamic applications.

Introducing a New Experimental Mode

Latest experimental mode revolves around two core concepts: <Suspense> and use cache.

When you add data fetching to your components, you’ll encounter a new error:

// app/page.tsx

async function Component() {
  return fetch(...) // error
}

export default async function Page() {
  return <Component />
}

Now, you can choose how to handle data: do you want it to be cached (on the server or client) or executed on every request? While fetch() serves as a primary example, this applies to any asynchronous Node API, such as database queries or timers.

Dynamic Fetching

If you’re still in the development phase or creating a highly dynamic dashboard, wrap your component in a <Suspense> boundary. This opts into dynamic data fetching and streaming:

// app/page.tsx
import { Suspense } from 'react';

async function Component() {
  return fetch(...) // no error
}

export default async function Page() {
  return <Suspense fallback="Loading..."><Component /></Suspense>
}

By using <Suspense>, the main structure of your app remains responsive. You can continue to add data within your page without triggering caching by default.

Static Content

For static content, you can leverage the new use cache directive:

// app/page.tsx
"use cache";

export default async function Page() {
  return fetch(...) // no error
}

Marking the page with use cache indicates that the entire segment should be cached, allowing static rendering without the need for <Suspense>.

Partial Caching

Mixing and matching is straightforward. You can apply use cache in your root layout, allowing it to cache while keeping individual pages dynamic as needed.

// app/layout.tsx

"use cache";
export default async function Layout({ children }) {
  const response = await fetch(...);
  const data = await response.json();
  return (
    <html>
      <body>
        <div>{data.message}</div>
        {children}
      </body>
    </html>
  );
}

Cached Functions

For more granular control over caching, you can apply use cache directly to async functions, similar to Server Actions, but designed for caching:

// app/layout.tsx

async function getMessage() {
  "use cache";
  const response = await fetch(...);
  const data = await response.json();
  return data.message;
}

export default async function Layout({ children }) {
  return (
    <html>
      <body>
        <h1>{await getMessage()}</h1>
        {children}
      </body>
    </html>
  );
}

This approach ensures that any changes to dynamic data trigger an error during the build, prompting you to reassess your choices regarding caching.

Cache Tagging

If you need to clear a specific cache entry, you can use the cacheTag() API:

// app/utils.ts

import { cacheTag } from 'next/cache';

async function getMessage() {
  'use cache';
  cacheTag('message-tag');
}

You can then use revalidateTag('message-tag') in a Server Action to clear the cache when needed.

Defining Cache Lifetimes

To control how long a cache entry should persist, use the cacheLife() API:

// app/page.tsx

"use cache";
import { unstable_cacheLife as cacheLife } from 'next/cache';

export default async function Page() {
  cacheLife("minutes");
  return <div>Content goes here...</div>;
}

This allows you to specify broad lifetimes like "seconds","minutes","hours","days","weeks","max" without the hassle of calculating exact time values.

Experimental Phase

This feature is still in its experimental phase and not yet ready for production.

To explore this new mode, ensure you’re using the canary version of Next.js:

npx create-next-app@latest

Enable the experimental dynamicIO flag in your next.config.ts:

// next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    dynamicIO: true,
  }
};

export default nextConfig;

Leave a Reply

Your email address will not be published. Required fields are marked *