DEV Community

Tamiz Uddin
Tamiz Uddin

Posted on • Originally published at tamiz.pro

Advanced Server-Side Caching Patterns in Next.js

Originally published on tamiz.pro.

Next.js, with its hybrid rendering capabilities, offers a robust platform for building performant web applications. While client-side caching is well-understood, effective server-side caching is crucial for optimizing server response times, reducing database and API load, and ensuring a snappier user experience, especially for routes that are frequently accessed but don't change often. This deep-dive explores advanced server-side caching strategies within the Next.js ecosystem, moving beyond basic static generation.

The Landscape of Server-Side Data Fetching in Next.js

Before diving into caching, it's essential to understand Next.js's primary server-side data fetching mechanisms:

  • getServerSideProps (Pages Router): Fetches data on every request, making it suitable for highly dynamic, personalized content. Without explicit caching, this can be a performance bottleneck.
  • getStaticProps (Pages Router): Fetches data at build time or on demand (with revalidate). Ideal for static or infrequently changing content. Next.js automatically caches the generated HTML and JSON for these pages.
  • Route Handlers (App Router): Allows you to create custom server-side APIs. Data fetching within these handlers can be cached or revalidated using standard web APIs (e.g., fetch with cache and next.revalidate options).
  • React Server Components (App Router): These components run on the server and can directly fetch data. Their output is streamed to the client. Next.js 13+ introduces a powerful data cache for fetch requests within RSCs and Route Handlers.

Next.js Data Cache (App Router)

With the introduction of the App Router and React Server Components, Next.js provides a powerful, built-in data cache, primarily leveraging the fetch API. This cache is persistent across requests and builds, and it's automatically used for fetch requests within getServerSideProps, getStaticProps, Route Handlers, and Server Components.

How fetch Caching Works

The fetch API in Next.js is extended to include powerful caching and revalidation options, mirroring HTTP caching headers but with more granular control.

// Default behavior: cache data forever (until revalidated or a new deployment)
async function getData() {
  const res = await fetch('https://api.example.com/items');
  // The return value is *not* serialized
  // You can return Date, Map, Set, etc.
  if (!res.ok) {
    // This will activate the closest `error.js` Error Boundary
    throw new Error('Failed to fetch data');
  }
  return res.json();
}

// Opt-out of caching for this specific fetch request
async function getDynamicData() {
  const res = await fetch('https://api.example.com/dynamic-data', { cache: 'no-store' });
  return res.json();
}

// Revalidate data after 60 seconds
async function getRevalidatedData() {
  const res = await fetch('https://api.example.com/stale-while-revalidate', { next: { revalidate: 60 } });
  return res.json();
}

// Using a custom cache tag for granular revalidation
async function getTaggedData() {
  const res = await fetch('https://api.example.com/tagged-data', { next: { tags: ['products'] } });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode
  • cache: 'force-cache' (default): Next.js will cache the resource and reuse it for subsequent requests.
  • cache: 'no-store': Next.js will fetch the resource on every request and not store it in the cache.
  • next: { revalidate: <seconds> }: Implements a stale-while-revalidate strategy. Data is cached, but after the specified seconds, Next.js will serve the stale data and re-fetch it in the background.
  • next: { tags: [...] }: Allows associating a fetch request with one or more tags. These tags can then be used to programmatically revalidate specific cached data using revalidateTag in a Route Handler or Server Action.

Programmatic Revalidation with Tags

Using cache tags, you can invalidate specific cached data based on events (e.g., a product update in your CMS). This is powerful for ensuring data consistency without rebuilding the entire application.

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const tag = request.nextUrl.searchParams.get('tag');

  if (!tag) {
    return NextResponse.json({ message: 'Missing tag param' }, { status: 400 });
  }

  revalidateTag(tag); // Invalidate all cached fetches associated with this tag

  return NextResponse.json({ revalidated: true, now: Date.now() });
}
Enter fullscreen mode Exit fullscreen mode

You could then trigger this endpoint from a webhook in your CMS or a background job whenever relevant data changes. For example, a POST request to /api/revalidate?tag=products would invalidate all fetch requests tagged with 'products'.

HTTP Caching with getServerSideProps (Pages Router) and Route Handlers

While the App Router's fetch caching is robust, for the Pages Router's getServerSideProps or for Route Handlers where you might not be using fetch (e.g., direct database calls), you can leverage standard HTTP caching headers to influence upstream CDNs or client-side caches.

Cache-Control Header

The Cache-Control header is the primary mechanism.

// pages/api/products/[id].ts (or getServerSideProps)
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  // Fetch data from database or external API
  const productId = req.query.id;
  const product = await fetchProductFromDB(productId);

  if (!product) {
    return res.status(404).json({ message: 'Product not found' });
  }

  // Cache for 60 seconds (shared cache like CDN) and 300 seconds (private client cache)
  // Stale-while-revalidate can be specified here too (e.g., stale-while-revalidate=30)
  res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
  res.status(200).json(product);
}
Enter fullscreen mode Exit fullscreen mode

Key directives:

  • public / private: public indicates that any cache (browser, proxy, CDN) can store the response. private means only the user's browser can cache it.
  • s-maxage=<seconds>: Specific to shared caches (like CDNs). Once expired, the CDN re-fetches.
  • max-age=<seconds>: Applies to all caches (browser and shared). s-maxage overrides max-age for shared caches.
  • stale-while-revalidate=<seconds>: Allows a cache to serve a stale response while it revalidates it in the background for X seconds after s-maxage or max-age expires.
  • no-cache: Forces revalidation with the origin server before serving a cached response.
  • no-store: Prevents any caching.

ETag and Last-Modified Headers

These headers enable conditional requests, reducing bandwidth by only sending a full response if the content has changed.

  • ETag: A unique identifier (hash) for a specific version of a resource. If the client sends an If-None-Match header with a matching ETag, the server can respond with 304 Not Modified.
  • Last-Modified: The date and time the resource was last modified. If the client sends an If-Modified-Since header with an earlier date, the server can respond with 304 Not Modified.

Implementing these requires custom logic to generate the ETag (e.g., hashing the response body) and check conditional headers. This is often handled by a CDN or reverse proxy, but can be done manually in Next.js Route Handlers.

// Example of ETag in a Route Handler
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function GET(request: NextRequest) {
  const data = { message: 'Hello, advanced caching!' };
  const body = JSON.stringify(data);
  const etag = crypto.createHash('md5').update(body).digest('hex');

  if (request.headers.get('if-none-match') === etag) {
    return new NextResponse(null, { status: 304 });
  }

  return new NextResponse(body, {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
      'ETag': etag,
      'Cache-Control': 'public, max-age=3600'
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Caching External API Responses (Custom Cache Layer)

For getServerSideProps or Route Handlers that interact with external APIs or databases directly (not via fetch), you might consider implementing a custom in-memory or external cache layer.

In-Memory Caching (e.g., node-cache)

For single-instance Next.js deployments, a simple in-memory cache can be effective. Be aware that this won't scale across multiple instances.

// utils/cache.ts
import NodeCache from 'node-cache';

const myCache = new NodeCache({ stdTTL: 300, checkperiod: 120 }); // TTL 5 minutes

export default myCache;

// pages/api/heavy-computation.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import myCache from '../../utils/cache';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const cacheKey = 'heavy_computation_result';
  let result = myCache.get(cacheKey);

  if (result) {
    console.log('Serving from cache');
    return res.status(200).json(result);
  }

  console.log('Performing heavy computation...');
  // Simulate heavy computation or expensive API call
  result = await new Promise(resolve => setTimeout(() => resolve({ value: Math.random() * 100 }), 2000));

  myCache.set(cacheKey, result);
  res.status(200).json(result);
}
Enter fullscreen mode Exit fullscreen mode

External Caching (e.g., Redis)

For scalable, distributed caching across multiple Next.js instances or serverless environments, an external cache like Redis is a better choice.

// utils/redis.ts
import { createClient } from 'redis';

const redisClient = createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379'
});

redisClient.on('error', err => console.error('Redis Client Error', err));

async function connectRedis() {
  if (!redisClient.isReady) {
    await redisClient.connect();
    console.log('Connected to Redis');
  }
}

connectRedis(); // Connect on module load

export default redisClient;

// pages/api/trending-articles.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import redisClient from '../../utils/redis';

const CACHE_TTL_SECONDS = 300; // 5 minutes

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const cacheKey = 'trending_articles';

  try {
    const cachedData = await redisClient.get(cacheKey);
    if (cachedData) {
      console.log('Serving trending articles from Redis cache');
      return res.status(200).json(JSON.parse(cachedData));
    }

    console.log('Fetching trending articles from external API...');
    const externalApiResponse = await fetch('https://api.example.com/trending');
    const articles = await externalApiResponse.json();

    await redisClient.setEx(cacheKey, CACHE_TTL_SECONDS, JSON.stringify(articles));
    console.log('Cached trending articles in Redis');
    return res.status(200).json(articles);
  } catch (error) {
    console.error('Error fetching or caching trending articles:', error);
    return res.status(500).json({ message: 'Internal server error' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Edge Caching with CDNs

For the highest performance and global distribution, integrating with a Content Delivery Network (CDN) is essential. Vercel, for instance, provides a powerful Edge Network that automatically caches static assets and can be configured to cache dynamic responses based on Cache-Control headers.

CDNs sit in front of your Next.js application, caching responses closer to your users. When a user requests content, the CDN first checks its cache. If available and fresh, it serves the content directly, bypassing your Next.js server entirely. This significantly reduces origin server load and latency.

CDN Configuration Considerations

  • Cache Keys: CDNs use a cache key (usually the URL) to store and retrieve content. You might need to configure custom cache keys if your content varies based on query parameters or headers that shouldn't be part of the default key.
  • Cache Invalidation: CDNs offer ways to purge specific cached items or entire cache zones. This is crucial for ensuring users always see up-to-date content after a change. Webhooks from your CMS can trigger CDN invalidation.
  • Edge Logic (e.g., Vercel Edge Functions, Cloudflare Workers): For highly dynamic scenarios, you can run serverless functions at the edge to modify requests/responses, personalize content, or implement more sophisticated caching logic before hitting your origin Next.js server.

Strategic Caching Considerations

  • Granularity: Cache at the appropriate level. Don't cache entire pages if only a small part changes frequently. Consider caching API responses or data chunks instead.
  • Freshness vs. Performance: Balance the need for up-to-the-minute data with the performance benefits of caching. stale-while-revalidate strategies are excellent for this.
  • Personalization: Never cache personalized content (private cache-control or no-store). For pages with both public and private sections, use a combination of server-side data fetching and client-side rendering for the personalized parts.
  • Invalidation Strategy: Plan your cache invalidation. Manual purges, time-based TTLs, event-driven invalidation (webhooks with tags), or a combination are common.
  • Monitoring: Monitor cache hit rates, server load, and response times to ensure your caching strategy is effective and identify any issues.

By strategically applying these advanced server-side caching patterns, Next.js developers can build highly performant, scalable, and resilient applications that deliver an excellent user experience while efficiently managing server resources.

Top comments (0)