Skip to content
Sponsor

React Router

Provide Astilba Cache to React Router v8 loaders and actions while carrying server request identity safely.

The current source exposes server middleware at @astilba/cache/react-router. It puts a Cache instance into React Router’s typed request context, carries an application-derived identity frame, drives background recovery ticks, and turns value-cache dependencies into scope-safe response tags.

Code location Use Astilba Cache? Why
Server loader or action Yes, through the middleware The factory and invalidation call stay on the server.
Server Component or another server framework Use the portable Cache API The current framework adapter is specifically for React Router v8.
API route or backend service Yes, through a runtime-owned Cache instance Cache is ordinary server-side TypeScript.
Client Component or browser-only SPA No Browser request state and component lifecycles need a client data library.

Build the Cache instance once for the server runtime. On Cloudflare, use createWorkersCache() as shown in Cloudflare Workers.

React Router v8 enables middleware by default and removes the flag. A React Router v7 application must first enable future.v8_middleware as described in the v7 upgrade guide. This adapter targets v8; register cacheMiddleware() in the root route module as described by the current middleware guide:

root.tsx
import { waitUntil } from "cloudflare:workers"
import type { MiddlewareFunction } from "react-router"
import { cacheMiddleware } from "@astilba/cache/react-router"
import { authMiddleware, authenticatedUserContext } from "./auth.server"
import { cache } from "./cache.server"
export const middleware: MiddlewareFunction[] = [
authMiddleware,
cacheMiddleware({
cache,
waitUntil,
request: ({ context }) => {
const user = context.get(authenticatedUserContext)
return { userId: user.id, tenant: user.tenantId }
},
}),
]

The request mapper is synchronous. Derive identity from a session or typed context that earlier trusted server middleware has already validated; do not treat an arbitrary client header as an authenticated principal.

If a request is intentionally anonymous, return {} or omit the mapper. With no visible principal and no explicit scope, the kernel resolves the call to the public storage class.

Use cacheContext to obtain the request’s Cache instance. Pass currentRequest() into each read so the kernel can derive its privacy scope:

routes/product.tsx
import type { Route } from "./+types/product"
import { t } from "@astilba/cache"
import {
cacheContext,
currentRequest,
} from "@astilba/cache/react-router"
export async function loader({ context, params }: Route.LoaderArgs) {
const cache = context.get(cacheContext)
const productId = params.productId
const product = await cache.getOrSet({
key: `product:${productId}`,
tags: [t`product:${productId}`],
request: currentRequest(),
factory: ({ signal }) => loadProduct(productId, signal),
})
return { product }
}

currentRequest() returns the frame opened by the root middleware. Outside that frame it returns undefined. Calling context.get(cacheContext) without installing the middleware throws instead of silently constructing another cache; cacheContext follows React Router’s no-default createContext() behavior.

After a mutation, change the source of truth first and invalidate through the same request Cache:

routes/product-update.ts
export async function action({ context, params, request }: Route.ActionArgs) {
const cache = context.get(cacheContext)
const productId = params.productId
const input = await parseProductUpdate(request)
await saveProduct(productId, input)
await cache.delete({ tag: t`product:${productId}` })
return { ok: true }
}

The adapter uses AsyncLocalStorage to make currentRequest() available throughout the request’s async call tree. On Cloudflare Workers, enable the narrow compatibility flag:

wrangler.jsonc (merge into your existing config)
{
"compatibility_flags": ["nodejs_als"]
}

You do not need the broader nodejs_compat flag for this adapter.

At request start, the middleware asks the cache’s replication poller whether a tick is due. It does not await that work before running loaders. Passing Cloudflare’s waitUntil function allows an in-flight tick to continue after the response returns; Cloudflare documents that lifecycle in its Context API guide.

The adapter limits ticks to at most one per second per Cache instance. The poller’s longer baseline, retry, and backoff schedules still decide whether a tick performs I/O. A failed tick is swallowed so it cannot fail the user’s response; provide a telemetry sink if you need to observe poll_tick_failed events. onSinkError observes a telemetry sink that throws or rejects without allowing that failure to escape either.

Without waitUntil, the tick is still started but is only best effort after the response lifecycle ends. Recovery does not become unsafe—the read path remains fail closed—but future requests may pay more live-check or refill work.

The middleware opens a request-scoped render collector around next(). Cache hits and successful fills automatically contribute their complete stored tag set and scope evidence. After the render, the middleware commits that collector against L3_BUDGET_DEFAULT—16 KB and 1,000 tag occurrences unless l3Budget overrides it.

The application still decides whether to opt into shared caching. The middleware never writes public or s-maxage:

Render result Header behavior
No managed Cache dependency Preserve existing cache headers; add Cache-Control: private only when the application supplied no policy.
Eligible public dependencies Preserve the application’s cache policy and replace Cache-Tag with deduplicated user tags. If no policy exists, default to private.
Private, unreadable, late, or over-budget dependency Force Cache-Control: private, remove Cache-Tag, and emit one l3_ineligible event.

Reserved per-key and per-namespace tags never reach the response header. One tenant- or principal-scoped dependency makes the whole response private, even if its tag was marked droppable. Responses with immutable header guards are rebuilt so redirects and fetch-derived responses do not turn into middleware errors.

The adapter exports L3_BUDGET_DEFAULT and L3_INELIGIBLE alongside the poll constants. CacheMiddlewareOptions accepts l3Budget, telemetry, and onSinkError in addition to cache, identity mapping, and waitUntil.

The response-tag path does not purge a CDN. See Cache HTTP responses for the complete safety and budget model, Control cache sharing for value storage, Consistency and resilience for recovery behavior, and Implementation status for current gaps.