Caching on Cloudflare
How Next.js caching translates to Cloudflare's model and ISR alternatives.
Next.js caching was designed for Vercel. On Cloudflare, you need KV, D1, or R2 for durable cache. ISR requires manual implementation.
Next.js caching was designed for Vercel's infrastructure. On Cloudflare, the model is fundamentally different.
The Architecture Difference
Cloudflare caches are per-PoP, not global. A cache miss in Tokyo doesn't benefit from a cache hit in London.
ISR: The Big Difference
Next.js ISR on Vercel uses a shared cache. On Cloudflare, you need to implement it differently.
Vercel ISR
// pages/posts/[id].tsx - Just works
export async function getStaticProps({ params }) {
return {
props: { post: await getPost(params.id) },
revalidate: 60, // Regenerate every 60 seconds
};
}Cloudflare Alternative
// Using KV for cache with manual revalidation
export async function GET(request: Request, { env }) {
const cacheKey = `post:${params.id}`;
// Check KV cache
const cached = await env.CACHE.get(cacheKey, 'json');
if (cached && Date.now() < cached.expiresAt) {
return Response.json(cached.data);
}
// Fetch fresh data
const post = await getPost(params.id);
// Store in KV with TTL
await env.CACHE.put(cacheKey, JSON.stringify({
data: post,
expiresAt: Date.now() + 60000
}), {
expirationTtl: 3600 // KV TTL (longer than stale threshold)
});
return Response.json(post);
}Cache API vs KV
Cloudflare offers two caching options:
| Feature | Cache API | Workers KV |
|---|---|---|
| Scope | Per-PoP | Global (eventually consistent) |
| Latency | ~1ms | ~50ms reads |
| Consistency | None | ~60s propagation |
| Best for | Hot data, same-region | Shared state, infrequent writes |
Cache API (Per-PoP)
export async function GET(request: Request) {
const cache = caches.default;
const cacheKey = new Request(request.url);
// Check cache
let response = await cache.match(cacheKey);
if (response) return response;
// Generate response
response = new Response(JSON.stringify(data), {
headers: {
'Cache-Control': 'public, max-age=60',
'Content-Type': 'application/json'
}
});
// Store in cache (non-blocking)
request.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}Workers KV (Global)
export async function GET(request: Request, { env }) {
const key = 'global-config';
// KV is globally distributed
const config = await env.CONFIG.get(key, 'json');
return Response.json(config);
}Static Assets
Static assets work well on Cloudflare Pages:
module.exports = {
assetPrefix: process.env.NODE_ENV === 'production'
? 'https://assets.example.com'
: undefined,
};Cloudflare's CDN handles static files with proper cache headers automatically.
Data Cache Replacement
Next.js fetch cache doesn't translate directly:
// ❌ Vercel-style fetch caching
const data = await fetch(url, {
next: { revalidate: 3600 }
});
// ✅ Cloudflare: Manual caching with Cache API
async function cachedFetch(url: string, ttl: number) {
const cache = caches.default;
const cacheKey = new Request(url);
const cached = await cache.match(cacheKey);
if (cached) return cached;
const response = await fetch(url);
const newResponse = new Response(response.body, {
headers: {
...response.headers,
'Cache-Control': `public, max-age=${ttl}`
}
});
await cache.put(cacheKey, newResponse.clone());
return newResponse;
}Cache Invalidation
Vercel has on-demand revalidation. On Cloudflare:
KV-based Invalidation
// Invalidate specific key
await env.CACHE.delete(`post:${postId}`);
// Invalidate by prefix (requires listing)
const keys = await env.CACHE.list({ prefix: 'post:' });
await Promise.all(keys.keys.map(k => env.CACHE.delete(k.name)));Cache API Purge
// Purge specific URL
const cache = caches.default;
await cache.delete(new Request('https://example.com/api/posts'));
// For broader purges, use Cloudflare API
await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: JSON.stringify({ files: ['https://example.com/api/posts'] })
});Practical Patterns
Stale-While-Revalidate
async function swr(key: string, fetcher: () => Promise<any>, ttl: number) {
const kv = env.CACHE;
const cached = await kv.get(key, 'json');
if (cached) {
if (Date.now() < cached.staleAt) {
return cached.data; // Fresh
}
// Stale - return but refresh in background
ctx.waitUntil(refresh(key, fetcher, ttl));
return cached.data;
}
return refresh(key, fetcher, ttl);
}
async function refresh(key: string, fetcher: () => Promise<any>, ttl: number) {
const data = await fetcher();
await env.CACHE.put(key, JSON.stringify({
data,
staleAt: Date.now() + ttl * 1000
}));
return data;
}Next: Migration Checklist - Step-by-step guide to moving from Vercel.