Skip to content
Go back

Implementing SSR Caching in Next.js

Implementing SSR Caching in Next.js

Introduction

Server-side rendering (SSR) caching in Next.js improves performance and reduces server load by storing rendered pages and API responses. This guide covers comprehensive caching strategies.

Prerequisites

Step 1: Page-Level Caching with Revalidation

Create app/products/[id]/page.tsx:

import { notFound } from 'next/navigation';

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
}

async function getProduct(id: string): Promise<Product | null> {
  try {
    const res = await fetch(`https://api.example.com/products/${id}`, {
      next: {
        revalidate: 3600, // Revalidate every hour
        tags: [`product-${id}`] // For on-demand revalidation
      }
    });

    if (!res.ok) return null;
    return res.json();
  } catch {
    return null;
  }
}

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id);

  if (!product) {
    notFound();
  }

  return (
    <div className="max-w-4xl mx-auto p-6">
      <div className="bg-white shadow-lg rounded-lg p-8">
        <h1 className="text-3xl font-bold text-gray-900 mb-4">
          {product.name}
        </h1>
        <p className="text-2xl text-green-600 font-semibold mb-4">
          ${product.price}
        </p>
        <p className="text-gray-700 leading-relaxed">
          {product.description}
        </p>
        <div className="mt-6 text-sm text-gray-500">
          Page cached and revalidated every hour
        </div>
      </div>
    </div>
  );
}

// Generate metadata with caching
export async function generateMetadata({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id);

  if (!product) {
    return {
      title: 'Product Not Found',
    };
  }

  return {
    title: product.name,
    description: product.description,
  };
}

Step 2: Static Generation with ISR

Create app/blog/[slug]/page.tsx:

interface BlogPost {
  slug: string;
  title: string;
  content: string;
  publishedAt: string;
  author: string;
}

async function getPost(slug: string): Promise<BlogPost | null> {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: {
      revalidate: 86400, // 24 hours
      tags: [`post-${slug}`, 'blog-posts']
    }
  });

  if (!res.ok) return null;
  return res.json();
}

async function getAllPosts(): Promise<BlogPost[]> {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 } // 1 hour
  });

  if (!res.ok) return [];
  return res.json();
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <article className="max-w-3xl mx-auto p-6">
      <header className="mb-8">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">
          {post.title}
        </h1>
        <div className="text-gray-600">
          <p>By {post.author}</p>
          <p>{new Date(post.publishedAt).toLocaleDateString()}</p>
        </div>
      </header>

      <div className="prose prose-lg max-w-none">
        {post.content}
      </div>
    </article>
  );
}

// Pre-generate popular blog posts
export async function generateStaticParams() {
  const posts = await getAllPosts();

  // Generate static pages for the first 100 posts
  return posts.slice(0, 100).map((post) => ({
    slug: post.slug,
  }));
}

Step 3: API Route Caching

Create app/api/products/route.ts:

import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const category = searchParams.get("category") || "all";
  const page = parseInt(searchParams.get("page") || "1");

  try {
    const products = await fetchProducts(category, page);

    const response = NextResponse.json(products);

    // Set cache headers
    response.headers.set(
      "Cache-Control",
      "public, s-maxage=3600, stale-while-revalidate=86400"
    );

    // Add custom cache tags
    response.headers.set("Cache-Tag", `products-${category}`);

    return response;
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to fetch products" },
      { status: 500 }
    );
  }
}

async function fetchProducts(category: string, page: number) {
  // Simulate API call with caching
  const cacheKey = `products-${category}-${page}`;

  // In production, use Redis or similar
  const cached = cache.get(cacheKey);
  if (cached) return cached;

  const products = await fetch(
    `https://api.example.com/products?category=${category}&page=${page}`
  ).then(res => res.json());

  // Cache for 30 minutes
  cache.set(cacheKey, products, 1800);

  return products;
}

// Simple in-memory cache (use Redis in production)
const cache = new Map();

Step 4: On-Demand Revalidation

Create app/api/revalidate/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { revalidateTag, revalidatePath } from "next/cache";

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

  // Validate secret token
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
  }

  try {
    const body = await request.json();
    const { type, identifier } = body;

    switch (type) {
      case "product":
        // Revalidate specific product
        revalidateTag(`product-${identifier}`);
        revalidatePath(`/products/${identifier}`);
        break;

      case "blog":
        // Revalidate specific blog post
        revalidateTag(`post-${identifier}`);
        revalidatePath(`/blog/${identifier}`);
        break;

      case "all-products":
        // Revalidate all product pages
        revalidateTag("products");
        revalidatePath("/products");
        break;

      default:
        return NextResponse.json(
          { error: "Invalid revalidation type" },
          { status: 400 }
        );
    }

    return NextResponse.json({
      message: `Revalidated ${type}: ${identifier}`,
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    return NextResponse.json({ error: "Revalidation failed" }, { status: 500 });
  }
}

Step 5: Redis Caching for API Routes

Create lib/redis.ts:

import { Redis } from "@upstash/redis";

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

export async function getCachedData<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T> {
  // Try to get cached data
  const cached = await redis.get<T>(key);
  if (cached) return cached;

  // Fetch fresh data
  const data = await fetcher();

  // Cache the data
  await redis.setex(key, ttl, data);

  return data;
}

export async function invalidateCache(pattern: string) {
  const keys = await redis.keys(pattern);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

Step 6: Advanced Caching with Middleware

Create middleware.ts:

import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Cache static assets aggressively
  if (
    request.nextUrl.pathname.startsWith("/images/") ||
    request.nextUrl.pathname.startsWith("/assets/")
  ) {
    response.headers.set(
      "Cache-Control",
      "public, max-age=31536000, immutable"
    );
  }

  // Cache API routes with shorter TTL
  if (request.nextUrl.pathname.startsWith("/api/")) {
    response.headers.set(
      "Cache-Control",
      "public, max-age=300, stale-while-revalidate=600"
    );
  }

  // Add cache headers for dynamic pages
  if (request.nextUrl.pathname.startsWith("/products/")) {
    response.headers.set(
      "Cache-Control",
      "public, s-maxage=3600, stale-while-revalidate=86400"
    );
  }

  return response;
}

export const config = {
  matcher: [
    "/api/:path*",
    "/images/:path*",
    "/assets/:path*",
    "/products/:path*",
  ],
};

Step 7: Cache Monitoring Component

Create components/CacheStatus.tsx:

'use client';

import { useEffect, useState } from 'react';

interface CacheInfo {
  lastRevalidated: string;
  nextRevalidation: string;
  cacheStatus: 'hit' | 'miss' | 'stale';
}

export default function CacheStatus() {
  const [cacheInfo, setCacheInfo] = useState<CacheInfo | null>(null);
  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    setIsClient(true);

    // Get cache info from headers or API
    fetch('/api/cache-info')
      .then(res => res.json())
      .then(setCacheInfo)
      .catch(() => setCacheInfo(null));
  }, []);

  if (!isClient || !cacheInfo) return null;

  return (
    <div className="fixed bottom-4 left-4 bg-gray-900 text-white text-xs p-2 rounded opacity-75">
      <div>Status: {cacheInfo.cacheStatus}</div>
      <div>Last: {new Date(cacheInfo.lastRevalidated).toLocaleTimeString()}</div>
      <div>Next: {new Date(cacheInfo.nextRevalidation).toLocaleTimeString()}</div>
    </div>
  );
}

Step 8: Environment Configuration

Update next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30,
      static: 180,
    },
  },

  // Configure caching headers
  async headers() {
    return [
      {
        source: "/api/:path*",
        headers: [
          {
            key: "Cache-Control",
            value: "public, s-maxage=300, stale-while-revalidate=600",
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

Summary

Next.js SSR caching with revalidation strategies, cache tags, and on-demand invalidation provides optimal performance. Combine static generation, incremental revalidation, and Redis for production-scale applications.


Share this post on:

Previous Post
Using Expo Router for Navigation in React Native
Next Post
Creating a PWA with React + Vite