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
- Next.js 13+ with App Router
- Basic understanding of SSR concepts
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.