Most Next.js performance guides stop at “use the built-in cache”. That works until you deploy several instances, run on a serverless platform where the file system is ephemeral, or need to invalidate a specific product page 200 milliseconds after a mutation. That is exactly where a Next.js Redis cache layer pays for itself.
This guide is a practical walkthrough: real code for App Router route handlers and server components, a key naming convention you can actually invalidate, TTL strategies, tag-based invalidation after mutations, and before/after benchmark numbers from a production-like setup. Everything below targets Next.js 15 and 16 with the App Router.
Why use Redis if Next.js already has caching?
Next.js ships with several cache layers: the Data Cache (fetch results), the Full Route Cache, the client Router Cache, and in newer versions the explicit 'use cache' directive. They are good defaults. They are not a distributed cache.
The built-in caches are stored on the local file system (or in memory) of each instance. Redis is a shared, network-attached, in-memory store that every instance sees at the same time.
| Need | Built-in Next.js cache | Redis layer |
|---|---|---|
Cache a fetch() to a public API |
Yes, ideal | Not needed |
| Cache a direct database or ORM query | Only with unstable_cache / 'use cache' |
Yes, full control |
| Shared cache across 10 pods or lambdas | No, per instance | Yes |
| Cache survives a redeploy | No | Yes (with key versioning) |
| Sub-second, targeted invalidation | Partial | Yes, tags and key sets |
| Rate limiting, sessions, locks, queues | No | Yes, same instance |
| Stale-while-revalidate on demand | Limited | Yes, you control the policy |
The rule we apply on client projects: keep the built-in cache for static rendering and remote fetches, and add Redis for expensive queries, computed aggregates, third-party API responses with rate limits, and anything that must be consistent across instances.

What we are building
- A safe Redis client singleton (no connection storms in dev or serverless).
- A
getOrSethelper with TTL, stale-while-revalidate and stampede protection. - A cached route handler at
/api/products. - A cached server component page.
- Tag-based invalidation triggered from a Server Action.
- Benchmarks before and after.
- Optional: Redis as the Next.js cache handler for ISR across instances.
Step 1: Install and configure the Redis client
For a Node.js runtime (containers, VPS, Node serverless functions), ioredis remains the most practical choice. If you deploy to the Edge runtime or a heavily serverless platform, use an HTTP-based client such as Upstash Redis instead, because Edge cannot open raw TCP sockets.
npm install ioredis
Create lib/redis.ts:
import Redis from 'ioredis';
const globalForRedis = globalThis as unknown as { redis?: Redis };
export const redis =
globalForRedis.redis ??
new Redis(process.env.REDIS_URL as string, {
// fail fast: a slow cache must never be slower than the database
connectTimeout: 1000,
commandTimeout: 250,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
keyPrefix: '', // we build prefixes manually, see step 2
});
redis.on('error', (err) => {
// never throw here, the app must keep serving from the database
console.error('[redis]', err.message);
});
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis;
Two details that matter a lot in real deployments:
- The singleton on
globalThisprevents a new connection on every hot reload in development. commandTimeoutandenableOfflineQueue: falsemake the cache fail open. If Redis is down, requests fall through to the database instead of hanging.
Step 2: Design cache keys you can actually invalidate
Bad keys are the number one reason Redis layers get ripped out six months later. Use a structured, versioned convention:
{app}:{schemaVersion}:{entity}:{operation}:{paramsHash}
Examples:
shop:v3:products:list:9f12ab34c7d0e5f1shop:v3:product:slug:red-sneakersshop:v3:user:42:dashboard
Guidelines we follow:
- Include a schema version (
v3). Change the payload shape? Bump the version and every old entry is orphaned instantly, no flush needed. - Hash the parameters, sorted, so
?page=2&category=shoesand?category=shoes&page=2produce the same key. - Never mix user-scoped and public data under the same key. If output depends on the session, the user or tenant id belongs in the key.
- Never use
KEYS *in production to invalidate. Track keys in tag sets instead (step 3).

Step 3: A reusable getOrSet helper
Create lib/cache.ts. This helper gives you TTL, stale-while-revalidate, tag registration and stampede protection in about 70 lines.
import crypto from 'node:crypto';
import { redis } from './redis';
const APP = 'shop';
const SCHEMA = 'v3';
type Entry<T> = { d: T; f: number }; // data, freshUntil (epoch ms)
export function cacheKey(entity: string, params: Record<string, unknown> = {}) {
const raw = JSON.stringify(params, Object.keys(params).sort());
const hash = crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16);
return `${APP}:${SCHEMA}:${entity}:${hash}`;
}
type Options<T> = {
key: string;
ttl: number; // seconds the value is considered fresh
swr?: number; // extra seconds it may be served stale while refreshing
tags?: string[];
fetcher: () => Promise<T>;
};
async function store<T>(o: Options<T>, data: T) {
const entry: Entry<T> = { d: data, f: Date.now() + o.ttl * 1000 };
const ttlTotal = o.ttl + (o.swr ?? 0);
const tx = redis.multi();
tx.set(o.key, JSON.stringify(entry), 'EX', ttlTotal);
for (const tag of o.tags ?? []) {
tx.sadd(`${APP}:${SCHEMA}:tag:${tag}`, o.key);
tx.expire(`${APP}:${SCHEMA}:tag:${tag}`, ttlTotal + 300);
}
await tx.exec();
}
async function refresh<T>(o: Options<T>): Promise<T> {
const data = await o.fetcher();
try { await store(o, data); } catch { /* cache write failure is not fatal */ }
return data;
}
export async function getOrSet<T>(o: Options<T>): Promise<T> {
let raw: string | null = null;
try { raw = await redis.get(o.key); } catch { /* fail open */ }
if (raw) {
const entry = JSON.parse(raw) as Entry<T>;
if (entry.f > Date.now()) return entry.d; // fresh hit
// stale hit: serve now, refresh in the background, one worker only
const lock = await redis
.set(`${o.key}:lock`, '1', 'EX', 30, 'NX')
.catch(() => null);
if (lock) void refresh(o).catch(() => {});
return entry.d;
}
return refresh(o); // miss
}
export async function invalidateTags(tags: string[]) {
for (const tag of tags) {
const setKey = `${APP}:${SCHEMA}:tag:${tag}`;
const keys = await redis.smembers(setKey).catch(() => [] as string[]);
if (keys.length) await redis.unlink(...keys).catch(() => {});
await redis.unlink(setKey).catch(() => {});
}
}
What each piece buys you
- Fresh window (
ttl): served straight from memory, single digit milliseconds. - Stale window (
swr): after the TTL expires, the user still gets an instant response while one background task refreshes the entry. No latency spike at expiry. SET NXlock: prevents a cache stampede where 500 concurrent requests all hit the database for the same expired key.- Tag sets: a Redis SET per tag holding the keys that used it, so invalidation is O(number of keys) instead of scanning the keyspace.
UNLINKinstead ofDEL: non-blocking deletion, important when a tag holds thousands of keys.
Step 4: Cache an App Router route handler
A typical uncached handler, hitting Postgres on every request:
// app/api/products/route.ts (before)
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(req: NextRequest) {
const category = req.nextUrl.searchParams.get('category') ?? 'all';
const page = Number(req.nextUrl.searchParams.get('page') ?? 1);
const products = await db.product.findManyWithStock({ category, page });
return NextResponse.json(products);
}
The same handler with the Redis layer:
// app/api/products/route.ts (after)
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { cacheKey, getOrSet } from '@/lib/cache';
export const dynamic = 'force-dynamic'; // we manage caching ourselves
export async function GET(req: NextRequest) {
const category = req.nextUrl.searchParams.get('category') ?? 'all';
const page = Number(req.nextUrl.searchParams.get('page') ?? 1);
const key = cacheKey('products:list', { category, page });
const products = await getOrSet({
key,
ttl: 60,
swr: 600,
tags: ['products', `products:cat:${category}`],
fetcher: () => db.product.findManyWithStock({ category, page }),
});
return NextResponse.json(products, {
headers: {
'X-Cache-Layer': 'redis',
'Cache-Control': 'public, max-age=0, s-maxage=60, stale-while-revalidate=600',
},
});
}
Notice the two tags. products is the broad one used when the catalogue structure changes, products:cat:shoes lets you invalidate a single category without wiping every list page.
Guard rail: do not cache unbounded query strings
If your endpoint accepts arbitrary filters, an attacker can generate millions of unique keys. Always whitelist and normalise parameters before hashing:
const ALLOWED_SORTS = ['price', 'name', 'created'] as const;
const sort = ALLOWED_SORTS.includes(rawSort as never) ? rawSort : 'created';
const page = Math.min(Math.max(Number(rawPage) || 1, 1), 50);
Step 5: Cache inside a server component
Server components can call Redis directly. Wrap the call in React’s cache() so that multiple components in the same render share one lookup (request-level deduplication), while Redis handles cross-request caching. Caching in next.js. Gift or Curse covers this in more depth.
// lib/queries/product.ts
import { cache } from 'react';
import { db } from '@/lib/db';
import { cacheKey, getOrSet } from '@/lib/cache';
export const getProductBySlug = cache(async (slug: string) =>
getOrSet({
key: cacheKey('product:slug', { slug }),
ttl: 300,
swr: 1800,
tags: ['products', `product:${slug}`],
fetcher: () => db.product.findBySlug(slug),
}),
);
// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getProductBySlug } from '@/lib/queries/product';
import { RelatedProducts } from './related';
export default async function ProductPage({
params,
}: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await getProductBySlug(slug);
if (!product) notFound();
return (
<main>
<h1>{product.name}</h1>
<p>{product.price} EUR</p>
{/* Streams separately, its own Redis key and TTL */}
<RelatedProducts categoryId={product.categoryId} />
</main>
);
}
Layered strategy that works well
- React
cache(): deduplicate within a single render. - Redis: share the result across requests, instances and deployments.
- Next.js Data Cache /
'use cache': keep it for remotefetch()calls and static route output. - CDN headers: absorb the remaining traffic for fully public responses.

Step 6: Invalidate after mutations
Caching is easy, invalidation is the actual product. In the App Router, mutations usually happen in Server Actions or POST route handlers. Two things must happen there: purge Redis, and tell Next.js to rebuild the affected routes.
// app/products/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { after } from 'next/server';
import { db } from '@/lib/db';
import { invalidateTags } from '@/lib/cache';
export async function updateProduct(id: string, formData: FormData) {
// 1. write to the source of truth first
const product = await db.product.update(id, {
name: String(formData.get('name')),
price: Number(formData.get('price')),
});
// 2. purge the Redis entries
await invalidateTags([
'products',
`product:${product.slug}`,
`products:cat:${product.categorySlug}`,
]);
// 3. purge the Next.js caches
revalidateTag('products');
revalidatePath(`/products/${product.slug}`);
// 4. protect against a racing read that repopulated a stale value
after(async () => {
await new Promise((r) => setTimeout(r, 500));
await invalidateTags([`product:${product.slug}`]);
});
return { ok: true };
}
The rules of safe invalidation
- Always write to the database first, then delete from the cache. Never the other way around.
- Delete, do not update. Writing the new value into Redis from the mutation path is a classic source of stale data when two writes interleave. Let the next read repopulate.
- Use the delayed second delete (step 4 above) when read traffic is heavy. A read that started before your write can otherwise store an outdated value right after your purge.
- Invalidate the narrowest tag that is still correct. Purging
productson every stock change destroys your hit rate. - Multi-instance note: Redis deletion is global, but
revalidateTagonly affects the instance that runs it unless you also use a shared cache handler (see the bonus section).
Step 7: TTL strategy cheat sheet
TTL should follow business tolerance for staleness, not gut feeling. This is the matrix we start from on e-commerce and SaaS projects:
| Data type | TTL | SWR window | Invalidation |
|---|---|---|---|
| Navigation, categories, CMS blocks | 1 to 24 h | 24 h | Webhook from the CMS |
| Product detail | 5 min | 30 min | Tag on save |
| Product listing, search facets | 60 s | 10 min | Tag on save |
| Dashboard aggregates, reports | 2 to 15 min | 1 h | Scheduled or on import |
| Third-party API with quotas | 10 to 60 min | 6 h | TTL only |
| Stock, price with promotions | 10 to 30 s | 0 | Event driven |
| Cart, checkout, auth state | Do not cache | n/a | n/a |
Add jitter to long TTLs. If 5,000 keys are created during the same warm-up and expire at the same second, you get a synchronised thundering herd. A simple fix:
const ttl = 3600 + Math.floor(Math.random() * 300); // 60 min plus up to 5 min
Benchmarks: before and after
Test setup used for the numbers below:
- Next.js 16 App Router, Node 22, standalone output, 2 vCPU / 4 GB container
- PostgreSQL 17 on a separate node, listing query joining products, stock and prices (about 180 ms of database time)
- Valkey 8 (Redis protocol compatible) in the same private network, sub-millisecond RTT
autocannon -c 50 -d 30against/api/products?category=shoes&page=1
| Metric | No cache | Redis cache | Change |
|---|---|---|---|
| Requests per second | 94 | 1,820 | 19x |
| Latency p50 | 198 ms | 11 ms | -94% |
| Latency p95 | 476 ms | 27 ms | -94% |
| Latency p99 | 731 ms | 44 ms | -94% |
| Database queries during the run | 2,820 | 3 | -99.9% |
| Peak app CPU | 98% | 61% | -37 pts |
| TTFB on the product page (field data, p75) | 640 ms | 180 ms | -72% |
Your absolute numbers will differ, but the shape is consistent: the win is proportional to the cost of the underlying query, and the Redis round trip itself is noise (typically 0.3 to 2 ms inside the same region, 20 to 80 ms if you put Redis in another continent, which you should never do).
Measure your own hit ratio
redis-cli info stats | grep keyspace
# keyspace_hits:184320
# keyspace_misses:2911
# hit ratio = 184320 / (184320 + 2911) = 98.4%
Below 80 percent, your TTLs are too short, your keys are too granular, or you are invalidating too broadly.

Bonus: Redis as the Next.js cache handler for ISR
Everything above is an application-level cache. There is a second, complementary use of Redis in Next.js: replacing the default file-system cache handler so that ISR pages and the Data Cache are shared by all instances. This is what you want when you run multiple pods behind a load balancer and do not want each one to regenerate the same page. For the wider picture, see How to Use Redis with Next.js.
// next.config.js
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // disable the default in-memory LRU
};
You can write the handler yourself (implementing get, set, revalidateTag) or use a maintained package. In 2026 the two most used options are @fortedigital/nextjs-cache-handler and the older @neshca/cache-handler. Next.js 16 also exposes cacheHandlers (plural) for the 'use cache' directive, with separate handlers per cache profile.
When to use which:
- Custom cache handler: shared ISR and Data Cache, consistent
revalidateTagacross instances, no code change in your components. - Application-level Redis (this tutorial): precise control over keys, TTL, SWR and invalidation for database queries and computed data.
- Both: the usual answer for high-traffic, multi-instance deployments.
Production checklist and common pitfalls
- Fail open, always. Every Redis call wrapped, timeouts set, no exception bubbling to the user.
- Set
maxmemoryandmaxmemory-policy allkeys-lruso the instance evicts instead of refusing writes. - Never store secrets or personal data without thinking about it. Enable TLS, restrict network access, and keep sensitive payloads out of shared caches.
- Watch payload size. Above roughly 100 KB per key, compress with gzip or split the object. Serialisation, not Redis, becomes the bottleneck.
- Serverless connection limits. Hundreds of concurrent lambdas opening TCP connections will exhaust your instance. Use an HTTP based client, a proxy, or a connection pooler.
- Do not cache authenticated responses in shared keys. Include the user or tenant id, or skip the cache.
- Bump the schema version on every breaking payload change. Deploying new code that reads old shapes causes runtime errors.
- Instrument it. Log hit, stale and miss per key namespace, and expose the hit ratio in your dashboard.
- Prefer
UNLINKandSCANoverDELandKEYSfor anything touching many keys. - Do not cache what is already fast. A 3 ms indexed lookup does not need a cache layer, it needs to stay simple.
FAQ
Does Next.js have caching built in?
Yes. Next.js caches remote fetch() results in the Data Cache, prerendered route output in the Full Route Cache, and client navigations in the Router Cache, plus explicit caching through unstable_cache or the 'use cache' directive in recent versions. Those caches are local to each instance and reset on redeploy, which is why a shared Redis layer is still valuable.
Can Redis be used as a cache?
Redis is one of the most widely used caches in production. It stores data in memory with sub-millisecond reads, supports TTL per key, atomic operations for locks and counters, and eviction policies such as LRU when memory fills up.
Is Redis an L1 or L2 cache?
In application architecture terms, Redis is an L2 cache: a shared, out-of-process cache behind your in-process L1 (React cache(), an in-memory LRU, or the Next.js in-memory cache). CPU L1/L2 terminology is unrelated to this. Scaling Next.js with Redis cache handler tackles the same question from another angle.
Next.js cache vs Redis: which should I use?
Use the built-in cache for static rendering and remote fetches, and Redis for database queries, computed aggregates and anything that must be identical and instantly invalidatable across several instances. They are complementary, not competing.
ioredis or node-redis?
Both work. ioredis has the more ergonomic API for cluster mode and pipelines, node-redis is the official client with a clean promise-based API. On the Edge runtime, use an HTTP client such as Upstash Redis instead, because raw TCP is not available.
How do I invalidate a Redis cache after a mutation in Next.js?
Write to the database first, then delete the affected keys using a tag set (SMEMBERS then UNLINK), then call revalidateTag or revalidatePath so Next.js rebuilds the routes. Under heavy read traffic, repeat the deletion after a few hundred milliseconds to defeat race conditions.
Is Next.js still relevant in 2026?
Yes. The App Router, server components and Server Actions are the mainstream way to build React applications at scale, and the caching model has kept maturing with pluggable cache handlers and explicit cache directives. The patterns in this guide apply to Next.js 15 and 16.
Need this implemented on your stack?
At Box Software we design and ship caching layers like this one for production Next.js applications: cache key architecture, TTL and invalidation policy, Redis or Valkey infrastructure, load testing and monitoring. If your pages are slow or your database is the bottleneck, get in touch and we will audit your rendering and caching strategy.
