Cache and PPR
Use shared runtime cache helpers, tag/path invalidation, ISR-style revalidation, and static shell caching for PPR pages.
Configure a shared cache
Farm uses its process-local memory cache when cache.adapter is not configured. For multiple
servers or ephemeral deployments, configure one shared adapter in farm.config.ts:
pnpm add @farm.js/cache-redis ioredis
import { defineConfig } from "@farm.js/core";import { redisCache } from "@farm.js/cache-redis";import Redis from "ioredis";export default defineConfig({ cache: { adapter: redisCache({ client: () => new Redis(process.env.REDIS_URL!), }), namespace: process.env.FARM_CACHE_NAMESPACE || "storefront", },});
The adapter is selected once. Routes, queries, endpoints, server functions, ISR, and PPR continue using Farm cache keys and invalidation helpers; application handlers do not import a Redis client.
When a shared adapter is present, Farm uses it as the authoritative cache instead of adding an incoherent process-local front cache.
Cache data
import { createFarmCacheKey, getFarmDataCache } from "@farm.js/core/cache";const cache = getFarmDataCache();const key = createFarmCacheKey(["products", "featured"]);const products = await cache.getOrSet(key, () => fetchProducts(), { tags: ["products"], paths: ["/pricing"], revalidate: 300,});
Revalidate
import { revalidatePath, revalidateTag } from "@farm.js/core/cache";revalidateTag("products");revalidatePath("/pricing");
PPR shell
export const experimental_ppr = true;export const revalidate = 60;export default function DashboardPage() { return <main>Static shell with dynamic sections</main>;}
Cache keys and tags
Use stable keys for data and broad tags for invalidation. Keys identify one cached value, while tags let multiple values be refreshed together.
const key = createFarmCacheKey(["products", productId]);const product = await cache.getOrSet(key, () => getProduct(productId), { tags: ["products", `product:${productId}`], paths: ["/pricing"], revalidate: 300,});
Invalidate after writes
After a mutation, invalidate the route path and any data tags that feed the page.
For structured route data and createServerQuery entries, call invalidate(["resource", id]). Farm uses the same route-data tag on the server and carries the invalidation to browser query and API consumers after a server action.
Server functions can declare the same relationship. Farm applies the targets only after the handler succeeds and its output passes validation:
import { createServerFn } from "@farm.js/core/server-fn";export const updateProduct = createServerFn({ input: UpdateProduct, invalidates: ({ input, result }) => [ { key: ["product", input.id] }, { key: ["products", "list"] }, { tag: "products" }, { path: `/products/${result.id}` }, ], async handler({ input }) { return db.product.update({ where: { id: input.id }, data: { name: input.name }, }); },});
Endpoints accept the same { key }, { tag }, and { path } targets. Imperative
invalidate(...), revalidateTag(...), and revalidatePath(...) remain supported. Await them
when calling outside a Farm action/endpoint request; Farm action requests automatically wait for
registered distributed invalidations before completing.
Adapter contract
Custom adapters implement asynchronous entry persistence plus optional shared tag versions:
import type { FarmCacheAdapter } from "@farm.js/core/cache";export const adapter: FarmCacheAdapter = { async get(key) { return backend.get(key); }, async set(key, entry) { await backend.set(key, entry); }, async delete(key) { await backend.delete(key); }, async getTagVersions(tags) { return backend.getTagVersions(tags); }, async invalidateTags(tags) { await backend.invalidateTags(tags); },};
getTagVersions and invalidateTags coordinate invalidation without scanning every cached entry.
Provider-specific adapters should use atomic increments or transactions when their backing service
supports them. Lease-capable adapters return an ownership token from acquireLease and only
release the lease when releaseLease(key, token) still matches that owner.
For any Farm/unstorage-compatible client, storageCacheAdapter(storage) provides a portable
baseline. @farm.js/cache-redis adds Redis-native atomic tag increments and regeneration leases.
import { revalidatePath, revalidateTag } from "@farm.js/core/cache";export async function POST(request: Request) { const input = await request.json(); await updateProduct(input); await revalidateTag("products"); await revalidatePath("/pricing"); return Response.json({ ok: true });}
PPR with Suspense holes
PPR works best when the stable page shell is outside Suspense and request-specific or slow data lives inside Suspense.
import { Suspense } from "react";export const experimental_ppr = true;export const revalidate = 60;export default function BillingPage() { return ( <main> <h1>Billing</h1> <Suspense fallback={<div>Loading subscription...</div>}> <SubscriptionStatus /> </Suspense> </main> );}
Observability events
Cache and PPR emit events such as cache.hit, cache.miss, cache.stale, cache.revalidatePath, cache.revalidateTag, ppr.shell.hit, ppr.shell.cached, and ppr.suspense.holeDetected. Subscribe in farm.config.ts when debugging refresh behavior.
Production notes
- Use one namespace per application or intentionally shared cache domain.
- Do not share authenticated values unless the cache key partitions by user or tenant.
- Treat adapter failures as production errors; declared mutations wait for invalidation.
- Use tags for data families, such as
productsorbilling. - Use paths for route-level invalidation, such as
/pricing. - Do not cache user-specific secrets in shared keys.
- Prefer PPR for pages with a mostly stable shell and a few dynamic sections.