Retrofit Redis-backed cache
Use when a project from nextjs-fullstack-starter (which uses Next.js's in-process cache by default) outgrows that. Typical triggers:
- The user is deploying multiple Next.js processes / containers and wants a shared cache.
- A blue-green deploy throws away the warm cache every release and that's becoming painful.
- The user wants to use
src/server/lib/cache.tsas a general-purpose Redis helper for non-Next.js caches too (rate limits, queues, etc.).
If the project ISN'T at one of those tipping points, push back gently — Next.js's default cache is fine until measurably not.
Pre-flight checks
Refuse if any of these is true:
src/server/modules/doesn't exist — the project wasn't scaffolded with this plugin.src/server/lib/cache.tsalready exists — Redis is already wired. Suggest/nfs-review-projectfor an audit instead.
Plan
- Add
ioredisto dependencies. Add@neshca/cache-handlerif the user wants the Next.js cache itself to go through Redis. - Add
redisservice todocker-compose.yml(under thecacheprofile so it doesn't always run). - Add
REDIS_URLtosrc/env.tsand.env.example. - Write
src/server/lib/cache.ts— a typed wrapper exposingget,set,del, and acached(fn, key, ttl)helper. - (Optional) Configure
next.config.ts'scacheHandlerto use@neshca/cache-handleragainst Redis. - Update
CLAUDE.mdwith the new pattern.
The cache helper
// src/server/lib/cache.ts
import "server-only";
import Redis from "ioredis";
import { env } from "@/env";
import { logger } from "@/server/lib/logger";
const log = logger.child({ module: "cache" });
let client: Redis | null = null;
function getClient(): Redis {
if (!client && env.REDIS_URL) {
client = new Redis(env.REDIS_URL, {
lazyConnect: true,
maxRetriesPerRequest: 2,
retryStrategy: (times) => Math.min(times * 100, 2000),
});
client.on("error", (err) => log.warn({ err }, "redis error"));
}
if (!client) throw new Error("REDIS_URL not set");
return client;
}
export const cache = {
async get<T>(key: string): Promise<T | null> {
try {
const raw = await getClient().get(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch (err) {
log.warn({ err, key }, "cache.get failed");
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
try {
const raw = JSON.stringify(value);
if (ttlSeconds) await getClient().setex(key, ttlSeconds, raw);
else await getClient().set(key, raw);
} catch (err) {
log.warn({ err, key }, "cache.set failed");
}
},
async del(key: string): Promise<void> {
try {
await getClient().del(key);
} catch (err) {
log.warn({ err, key }, "cache.del failed");
}
},
/**
* Read-through cache wrapper. Returns the cached value if present, else
* computes via `compute`, stores, and returns.
*/
async cached<T>(key: string, ttlSeconds: number, compute: () => Promise<T>): Promise<T> {
const hit = await this.get<T>(key);
if (hit !== null) return hit;
const fresh = await compute();
await this.set(key, fresh, ttlSeconds);
return fresh;
},
};
When to reach for cache.cached vs. "use cache" directive
| Need | Use |
|---|---|
| Cache a Server Component / service read for Next.js to invalidate by tag | "use cache" + cacheTag + updateTag (built-in) |
| Cache something that isn't a server read (rate-limit counter, queue, ad-hoc key) | cache.get / cache.set directly |
| Cache an expensive computation across processes | cache.cached(key, ttl, fn) |
The two systems coexist — Next.js's "use cache" handles the server-render cache; cache.* is your own general-purpose Redis client.
Update CLAUDE.md
Add to the stack list:
- **Redis** via `ioredis`. Helper at `src/server/lib/cache.ts`. Use for non-Next.js caches (rate limits, queues, ad-hoc keys). Next.js's `"use cache"` + `updateTag` continue to handle server-render caching independently.
Update the architecture section to mention which keys to cache and which not (per the caching.md reference).
Verification
pnpm install
docker compose --profile cache up -d # starts the redis container
REDIS_URL=redis://localhost:6379 pnpm verify
Anti-patterns to refuse
- Replacing the Next.js cache wholesale with the ioredis helper. They serve different purposes. Server-render caching belongs to Next.js's primitives; ad-hoc caching is what the helper is for.
- Caching per-user data without explicit user-scoped keys. Easy way to leak data across users. If you cache anything user-scoped, the key MUST include the userId.
- Using
cache.*to "fix" slow queries. Caching is a workaround. If a query is slow, fix the query or add an index first; only cache when there's no further optimization room.