Cache expensive work with the use cache directive, set how long it lasts with cacheLife, and refresh it on demand with tags.
Why: in this Next.js, data is NOT cached by default — you opt in. First enable the feature in your config; then you can mark exactly what should be cached. Note: "caching" means saving a result so the next request is served instantly instead of recomputed.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfigWhy: put "use cache" at the top of an async function to store its result. Its arguments become the cache key automatically, so getUser("a") and getUser("b") are cached separately. Great for data reused across the app.
// app/lib/data.ts
import { cacheLife } from 'next/cache'
export async function getProducts() {
'use cache'
cacheLife('hours') // see next topic
return db.query('SELECT * FROM products')
}Why: cacheLife says how long cached data stays fresh before Next.js refreshes it. Pass a named profile like "minutes", "hours", or "days", or a custom object of seconds for fine control. The custom object has three fields, each a widening "how old is OK" ring: stale = how long the browser reuses its own copy without asking the server; revalidate = after this, the server serves the old copy instantly but rebuilds a fresh one in the background; expire = the hard limit — if the page gets no traffic for this long, the next visitor waits for a fresh copy. Rule: expire must be larger than revalidate, or Next.js errors.
import { cacheLife } from 'next/cache'
export async function getRates() {
'use cache'
// Named profile: seconds | minutes | hours | days | weeks | max
cacheLife('minutes')
// Or a custom object (all values in seconds):
// cacheLife({ stale: 3600, revalidate: 7200, expire: 86400 })
// stale: 3600 -> browser reuses its copy for 1 hour
// revalidate: 7200 -> after 2 hours, refresh in the background
// expire: 86400 -> after 24 hours idle, wait for fresh data
return fetch('https://api.example.com/rates').then((r) => r.json())
}Why: tag cached data with cacheTag, then after a change call revalidateTag with the same tag to refresh it. revalidateTag serves the old value while loading the new one in the background — good when a small delay is fine.
// app/lib/data.ts
import { cacheTag } from 'next/cache'
export async function getPosts() {
'use cache'
cacheTag('posts')
return db.query('SELECT * FROM posts')
}
// app/lib/actions.ts
import { revalidateTag } from 'next/cache'
export async function refreshPosts() {
revalidateTag('posts', 'max') // background refresh, serve stale meanwhile
}Why: all three throw away cached data so it gets rebuilt — they differ in how you target it and whether the user waits. updateTag (Server Actions only) expires a tag right away, so the next request WAITS for fresh data — use it when the person who just saved must see their own change instantly ("read-your-own-writes"). revalidateTag(tag, "max") targets the same tags but refreshes in the BACKGROUND — it serves the old value instantly while rebuilding, so it stays fast and also works in Route Handlers (webhooks/APIs), where updateTag is not allowed. revalidatePath refreshes a whole ROUTE by its URL — the blunt option for when you did not set up tags. Prefer tags over paths: they refresh exactly the data that changed.
import { updateTag, revalidateTag, revalidatePath } from 'next/cache'
// Server Action only — user sees their own change NOW (next request waits):
updateTag('posts')
// Anywhere (incl. Route Handlers) — refresh in the background, stay fast:
revalidateTag('posts', 'max') // serves old value while rebuilding
// No tags set up? Refresh a whole route by its path (less precise):
revalidatePath('/blog')