How React Router 7 + Vercel CDN caching works for docs pages.

Caching Model

This page documents how caching is implemented for the smallprotocol.dev documentation. The pattern is reusable for other React Router 7 (RR7) apps deployed to Vercel.


Current Caching Policy

Docs are always fresh during launch stabilization. Both HTML responses and .data loader responses are served with no-store, so the CDN does not cache them.


Where Caching Happens

flowchart LR
  Browser --> CDN["Vercel CDN (Edge)"]
  CDN --> Origin["Serverless Function"]

  CDN -->|"HIT"| Browser
  CDN -->|"MISS"| Origin
  Origin --> CDN

With no-store set, Vercel's CDN is bypassed and always forwards requests to the origin.


Cache Control Headers

The docs routes use these headers:

text
Cache-Control: no-store
Cdn-Cache-Control: no-store

What This Means

  • The browser does not cache responses.
  • The Vercel CDN does not cache responses.
  • Every request goes to the origin, so updates appear immediately after deploy.

Expected Headers

When testing with curl -I, you'll see these headers:

bash
curl -I https://smallprotocol.dev/docs
text
HTTP/2 200
cache-control: no-store
cdn-cache-control: no-store
x-vercel-cache: MISS
age: 0

Key Headers to Check

HeaderDescription
x-vercel-cacheMISS (origin hit)
ageAlways 0 when uncached
cache-controlShould be no-store
cdn-cache-controlShould be no-store
varyHeaders that affect cache key (usually Accept-Encoding)

How to Test

Test HTML Document Headers

bash
curl -I https://smallprotocol.dev/docs/small-v1

Test .data Loader Headers

bash
curl -I https://smallprotocol.dev/docs/small-v1.data

Expected Pattern

Every request should return cache-control: no-store, cdn-cache-control: no-store, x-vercel-cache: MISS, and age: 0.

Local Self-Check Script

bash
bun run docs:selfcheck

Override the target URL if needed:

bash
DOCS_BASE_URL=https://smallprotocol.dev bun run docs:selfcheck

Implementation

Loader Headers (Route Level)

Each docs route exports headers that enforce no-store:

typescript
// app/modules/docs/routes/doc.route.tsx
import { data as routerData } from "react-router";
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";

const DOCS_NO_STORE = "no-store";

export async function loader({ params }: LoaderFunctionArgs) {
  const doc = await loadDocHtml(params.slug ?? "docs");

  return routerData(doc, {
    headers: {
      "Cache-Control": DOCS_NO_STORE,
      "Cdn-Cache-Control": DOCS_NO_STORE,
    },
  });
}

export const headers: HeadersFunction = ({ loaderHeaders }) => {
  return {
    "Cache-Control": loaderHeaders.get("Cache-Control") ?? DOCS_NO_STORE,
    "Cdn-Cache-Control":
      loaderHeaders.get("Cdn-Cache-Control") ?? DOCS_NO_STORE,
  };
};

Server Response Headers (Entry Server)

The entry.server.tsx ensures docs routes get the correct headers for both HTML and .data responses:

typescript
// app/entry.server.tsx
const DOCS_NO_STORE = "no-store";

// In handleRequest:
const { pathname } = new URL(request.url);
const isDocsHtml = pathname === "/docs" || pathname.startsWith("/docs/");
const isDocsData = pathname.endsWith(".data") && pathname.startsWith("/docs");

if (isDocsHtml || isDocsData) {
  responseHeaders.set("Cache-Control", DOCS_NO_STORE);
  responseHeaders.set("Cdn-Cache-Control", DOCS_NO_STORE);
}

// Ensure CDN variance is explicit
responseHeaders.set("Vary", "Accept-Encoding");

Applying to Other RR7 Apps

To keep content always fresh during stabilization:

1. Define No-Store Policy

typescript
const NO_STORE = "no-store";

2. Add to Route Loaders

typescript
export async function loader() {
  const data = await fetchData();
  return routerData(data, {
    headers: {
      "Cache-Control": NO_STORE,
      "Cdn-Cache-Control": NO_STORE,
    },
  });
}

export const headers: HeadersFunction = ({ loaderHeaders }) => ({
  "Cache-Control": loaderHeaders.get("Cache-Control") ?? NO_STORE,
  "Cdn-Cache-Control": loaderHeaders.get("Cdn-Cache-Control") ?? NO_STORE,
});

3. Handle in Entry Server

typescript
// entry.server.tsx
const { pathname } = new URL(request.url);
const isDocsRoute = pathname.startsWith("/docs");

if (isDocsRoute) {
  responseHeaders.set("Cache-Control", NO_STORE);
  responseHeaders.set("Cdn-Cache-Control", NO_STORE);
  responseHeaders.set("Vary", "Accept-Encoding");
}

4. Verify with curl

bash
curl -I https://your-app.vercel.app/docs
curl -I https://your-app.vercel.app/docs.data

Caveats

  • Personalized content: Keep no-store for user-specific content
  • Cache invalidation: Not needed when no-store is set
  • Development: Local dev mirrors no-store behavior; test on preview/production deployments
  • Vary header: Always include Vary: Accept-Encoding for compressed responses

Further Reading