Caching and Revalidation

Route segment config, unstable_cache, revalidateTag / revalidatePath and stale-while-revalidate.

Cache route handler responses and data, then revalidate them on a timer or on demand. Stale entries are served instantly while one background refresh runs.

Route handlers

GET route handlers are dynamic by default. Opt into caching with route segment config:

app/api/posts/route.tsts
export const revalidate = 60;export async function GET() {  const posts = await db.posts.latest();  return Response.json(posts);}
ExportBehavior
(none)Runs on every request
dynamic = "force-static"Cached until revalidated
revalidate = NCached; stale after N seconds
revalidate = 0 or dynamic = "force-dynamic"Runs on every request

Cached handlers get one entry per pathname, and their request looks like it would at build time: no query string, headers, cookies or body. Read those in a dynamic handler instead. Only GET and HEAD are cached. Responses with a 5xx status, a Set-Cookie header, or Cache-Control: private / no-store are returned but never stored.

Headers

Cached responses carry:

HeaderValue
x-textjs-cacheMISS, HIT or STALE
Cache-Controls-maxage=N, stale-while-revalidate=<1 year − N>, or s-maxage=31536000 for force-static

When middleware sets cookies, rewrites or overrides request headers for the request, the generated header becomes private, no-cache, no-store, max-age=0, must-revalidate, so shared caches never replay a per-visitor decision. A Cache-Control header set by the handler is kept.

In text dev, segment config applies but nothing is stored, so every request runs the handler.

Data cache

Cache any async function with unstable_cache from @textjs/next/cache:

lib/posts.tsts
import { unstable_cache } from "@textjs/next/cache";export const getPosts = unstable_cache(  async (author: string) => db.posts.byAuthor(author),  ["posts"],  { tags: ["posts"], revalidate: 300 },);

The key is the key parts plus the arguments, encoded by value (undefined and null, Map, Set, Date and URL arguments stay distinct). Without key parts, the function's source and the place it was created identify it; pass key parts whenever the function closes over values, for example when it's created per request or in a loop. Results are JSON-serialized, so every call returns a fresh copy (and Date objects come back as strings); a result that can't be serialized throws. cache(fn, { key, tags, revalidate }) takes the same settings as an options object.

Inside a cached route handler, data entries pass their tags and their shortest revalidate up to the route: revalidating "posts" also refreshes /api/posts. A data call with revalidate: 0 makes the route dynamic.

Stale-while-revalidate

Once an entry is older than revalidate:

  1. The next request receives the stale value immediately (STALE).
  2. One refresh runs in the background; concurrent requests share it.
  3. If the refresh throws, the stale value keeps being served and the error is logged.

On Vercel and Cloudflare, background refreshes are registered with waitUntil(). Entries older than a year are always recomputed in the foreground.

On-demand revalidation

app/api/revalidate/route.tsts
import { revalidatePath, revalidateTag } from "@textjs/next/cache";export async function POST(request: Request) {  // Anyone can POST here: require a secret shared with your CMS or webhook.  if (request.headers.get("authorization") !== `Bearer ${process.env.REVALIDATE_SECRET}`) {    return new Response("Unauthorized", { status: 401 });  }  await revalidateTag("posts");  await revalidatePath("/api/feed");  return Response.json({ revalidated: true });}
CallInvalidates
revalidateTag("posts")Everything tagged posts; the next request recomputes
revalidateTag("posts", "max")Marks it stale; the next request is served stale while it refreshes
revalidatePath("/api/feed")That path
revalidatePath("/api/posts/[id]")Every path of a dynamic route
revalidatePath("/api", "layout")Everything under /api

Both return a promise. With the in-memory store the invalidation is immediate; await it when a custom store is asynchronous.

Custom cache stores

The default store is an in-memory LRU (1,000 entries, about 50 MB) per server instance. To share a cache across instances, point cacheHandler at a module that default-exports a CacheStore:

text.config.tsts
export default defineConfig({ cacheHandler: "./cache-handler.ts" });
cache-handler.tsts
import type { CacheStore } from "@textjs/next/cache";const store: CacheStore = {  async get(key) { return redis.get(key).then((v) => (v ? JSON.parse(v) : undefined)); },  async set(key, entry) { await redis.set(key, JSON.stringify(entry)); },  async delete(key) { await redis.del(key); },  async markTags(tags, mode, at) { for (const t of tags) await redis.hset(`tag:${t}`, mode, at); },  async getTagState(tags) { /* max expiredAt / staleAt across tags */ return { expiredAt: 0, staleAt: 0 }; },};export default store;

Entries are plain JSON. setCacheStore(store) installs a store at runtime instead.

Platform notes

  • Each serverless instance (Vercel functions, Cloudflare isolates) has its own in-memory cache. Cache-Control: s-maxage lets the platform CDN cache responses too; on-demand revalidation does not purge a CDN. Use a shared cacheHandler when revalidateTag must reach every instance.
  • Cloudflare requires the nodejs_compat flag (the cache uses AsyncLocalStorage). See Deploying.