Build This Now
Build This Now
Qu'est-ce que le code Claude ?Installer Claude CodeL'installateur natif de Claude CodeTon premier projet Claude Code
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email Sequencesv2.1.122 Release NotesClaude Code Dynamic Workflows : comment orchestrer 1 000 sous-agents sur une vraie codebaseBonnes pratiques Claude CodeMeilleures pratiques pour Claude Opus 4.7Claude Code sur un VPSIntégration GitRevue de code avec Claude CodeLes Worktrees avec Claude CodeClaude Code à distanceClaude Code ChannelsChannels, Routines, Teleport, DispatchTâches planifiées avec Claude CodePermissions Claude CodeLe mode auto de Claude CodeAjouter les paiements Stripe avec Claude CodeFeedback LoopsWorkflows TodoGestion des tâches dans Claude CodeTemplates de projetTarification et utilisation des tokens Claude CodeTarifs de Claude Code : ce que tu vas vraiment payerClaude Code Ultra ReviewConstruire une app Next.js avec Claude CodeSupabase DatabaseVercel DeepsecTest-Driven DevelopmentConstruire un MVP SaaS avec Claude CodeAjouter l'authentification avec Claude Code (Supabase Auth)Ajouter les emails transactionnels avec Claude Code (Resend + React Email)Construire une API type-safe avec Claude Code (oRPC + Zod)File UploadsBackground Jobs (Inngest)Admin DashboardFull-Text SearchCommerce agentique : comment construire une app que les agents IA peuvent payer1M ContextUser API KeysAudit LogsCSV Import PipelineDatabase MigrationsProduction Error TrackingFeature FlagsGitHub ActionsHeadless ModeMax Plan vs APICaching and RevalidationIn-App NotificationsOutbound WebhooksPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based BillingCombien coûte la création d'un SaaS avec Claude Code en 2026Parallel AI AgentsCoding Agent Injection
speedy_devvkoen_salo
Blog/Handbook/Workflow/Caching and Revalidation

Caching and Revalidation

A Next.js 16 caching strategy for multi-tenant SaaS: cache components and partial prerendering, use cache with cacheLife and cacheTag, per-tenant cache keys, and cachetag revalidate calls fired from oRPC mutations.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →
speedy_devvWritten by speedy_devvPublished Jul 22, 202611 min readHandbook hubWorkflow index

Caching in Next.js 16 is no longer a set of scattered fetch options. It is one directive, one lifetime function, one tag function, and a rule about what may cross the cache boundary. Get that rule wrong in a multi-tenant SaaS and you will not get a slow page, you will get one customer looking at another customer's numbers. This guide wires the whole thing up with Claude Code doing the typing.

Turn On Cache Components

Everything here depends on one config flag. cacheComponents replaces the old experimental.ppr, experimental.useCache, and experimental.dynamicIO flags and switches the App Router into partial prerendering mode, where a single route can contain static, cached, and dynamic regions at once.

The same config file is where you define custom cache profiles, so add one now for the dashboard shell you are about to build.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    dashboard: {
      stale: 60, // client holds it for 1 minute
      revalidate: 300, // server refreshes every 5 minutes
      expire: 3600, // hard ceiling of 1 hour
    },
  },
};

export default nextConfig;

Two limitations worth knowing before you go further. Cache Components require a Node.js server, so static export is out, and on serverless hosts the in-memory cache often does not survive between requests. Build-time caching still works everywhere.

Three Kinds of Content on One Page

Partial prerendering only pays off if you are deliberate about which region is which. Synchronous markup with no awaits gets prerendered into a static shell and served instantly. Anything async and cacheable goes behind "use cache". Anything that must be fresh per request goes inside a <Suspense> boundary and streams in after the shell.

A dashboard page makes the split obvious. The heading and the plan callout never change per user, usage totals are fine a few minutes stale, and the activity feed has to be live.

// app/(app)/dashboard/page.tsx
import { Suspense } from "react";
import { getSession } from "@/lib/auth";
import { UsageChart } from "@/components/usage-chart";
import { LiveActivity } from "@/components/live-activity";
import { PlanCallout } from "@/components/plan-callout";
import { CardSkeleton } from "@/components/card-skeleton";

export default function DashboardPage() {
  return (
    <main className="mx-auto max-w-5xl space-y-6 px-6 py-10">
      <h1 className="text-2xl font-semibold">Dashboard</h1>
      <PlanCallout />

      <Suspense fallback={<CardSkeleton />}>
        <UsagePanel />
      </Suspense>

      <Suspense fallback={<CardSkeleton />}>
        <ActivityPanel />
      </Suspense>
    </main>
  );
}

async function UsagePanel() {
  const { workspaceId } = await getSession();
  return <UsageChart workspaceId={workspaceId} />;
}

async function ActivityPanel() {
  const { workspaceId } = await getSession();
  return <LiveActivity workspaceId={workspaceId} />;
}

Notice that the page component is not async. The session read lives inside the Suspense boundaries, because getSession touches cookies and that would drag the whole route out of the static shell. The workspace id it returns is then handed to the cached component as a plain string prop.

The cached component is where "use cache" goes. It declares a lifetime, declares a tag, and takes everything it needs as arguments.

// components/usage-chart.tsx
import { cacheLife, cacheTag } from "next/cache";
import { getUsageTotals } from "@/lib/usage";
import { tags } from "@/lib/cache-tags";

export async function UsageChart({ workspaceId }: { workspaceId: string }) {
  "use cache";
  cacheLife("dashboard");
  cacheTag(tags.usage(workspaceId));

  const totals = await getUsageTotals(workspaceId);
  const pct = Math.min(100, Math.round((totals.events / totals.limit) * 100));

  return (
    <section className="rounded-xl border p-6">
      <h2 className="text-sm font-medium text-muted-foreground">
        Usage this month
      </h2>
      <p className="mt-2 text-3xl font-semibold tabular-nums">
        {totals.events.toLocaleString()}
      </p>
      <p className="text-sm text-muted-foreground">
        of {totals.limit.toLocaleString()} included events
      </p>
      <div className="mt-4 h-2 rounded-full bg-muted">
        <div
          className="h-2 rounded-full bg-primary"
          style={{ width: `${pct}%` }}
        />
      </div>
    </section>
  );
}

The Leak Nobody Catches in Review

Here is the failure mode specific to multi-tenant apps, and it survives code review because it looks correct.

Cache keys are generated from the build id, a hash of the function, its serializable arguments, and any variables captured from the enclosing scope. Nothing about the request is in there. So a cached lookup keyed on a value that is only unique inside one tenant will happily serve tenant A's row to tenant B.

This version is broken. Project slugs are unique per workspace, not globally, so the first workspace to load /p/website fills the entry and every other workspace with a project called "website" reads it.

// components/project-header.tsx  (broken)
import { cacheTag } from "next/cache";
import { getProjectBySlug } from "@/lib/projects";

export async function ProjectHeader({ slug }: { slug: string }) {
  "use cache";
  cacheTag(`project:${slug}`);

  const project = await getProjectBySlug(slug);
  return <h2 className="text-xl font-semibold">{project.name}</h2>;
}

The fix is one argument and one tag change. The workspace id enters the cache key because it is an argument, and it enters the tag so invalidation stays scoped to that tenant.

// components/project-header.tsx  (fixed)
import { cacheTag } from "next/cache";
import { getProjectBySlug } from "@/lib/projects";
import { tags } from "@/lib/cache-tags";

export async function ProjectHeader({
  workspaceId,
  slug,
}: {
  workspaceId: string;
  slug: string;
}) {
  "use cache";
  cacheTag(tags.project(workspaceId, slug));

  const project = await getProjectBySlug(workspaceId, slug);
  return <h2 className="text-xl font-semibold">{project.name}</h2>;
}

There is a second half to this. Inside a cached function you cannot create a Supabase client from the request cookies, which means Row Level Security is not standing behind your query any more. The data layer has to filter explicitly, every time.

// lib/projects.ts
import { createClient } from "@supabase/supabase-js";

const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } },
);

export async function getProjectBySlug(workspaceId: string, slug: string) {
  const { data, error } = await admin
    .from("projects")
    .select("id, name, slug, created_at")
    .eq("workspace_id", workspaceId)
    .eq("slug", slug)
    .maybeSingle();

  if (error) throw new Error(`project lookup failed: ${error.message}`);
  if (!data) throw new Error("Project not found");

  return data;
}

Every function called from inside a cached scope takes workspaceId as its first parameter and puts it in the where clause. Treat that as a hard convention, not a style preference. It is the only thing between a cached read and a cross-tenant disclosure.

Verify the Tenant Before It Becomes a Cache Key

Passing the workspace id as an argument only helps if that id was earned. If it comes from a URL segment or a query string, an attacker types someone else's workspace id into the address bar and your cache dutifully keys on it.

Resolve the session, confirm membership, and return the id from there. This runs outside every cached scope, on every request.

// lib/auth.ts
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";

export type Session = {
  userId: string;
  workspaceId: string;
  role: "owner" | "admin" | "member";
};

export async function getSession(): Promise<Session> {
  const cookieStore = await cookies();

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: () => {},
      },
    },
  );

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) throw new Error("Not authenticated");

  const { data: membership } = await supabase
    .from("workspace_members")
    .select("workspace_id, role")
    .eq("user_id", user.id)
    .eq("is_active", true)
    .maybeSingle();

  if (!membership) throw new Error("No active workspace");

  return {
    userId: user.id,
    workspaceId: membership.workspace_id,
    role: membership.role,
  };
}

If the route is /w/[workspace]/p/[slug], do not trust the segment. Await params, then check the slug in that segment against the membership you just resolved, and only then hand the id to a cached component.

A Tag Vocabulary You Write Once

Tags are strings, which means typos are silent. A cached component tagged usage:${id} and a mutation calling revalidateTag("usage-" + id) will both pass type checking and the cache will never clear.

One module fixes that. It also documents your invalidation surface in a single place, which is exactly the file to point Claude Code at when you ask it to add a mutation.

// lib/cache-tags.ts

// Tags are case sensitive and capped at 256 characters.
// Every tenant-scoped tag carries the workspace id.
export const tags = {
  usage: (workspaceId: string) => `usage:${workspaceId}`,
  billing: (workspaceId: string) => `billing:${workspaceId}`,
  projects: (workspaceId: string) => `projects:${workspaceId}`,
  project: (workspaceId: string, slug: string) =>
    `project:${workspaceId}:${slug}`,
  members: (workspaceId: string) => `members:${workspaceId}`,

  // Global, tenant independent content.
  marketingPosts: () => "marketing:posts",
  changelog: () => "changelog",
} as const;

Picking a Cache Life

cacheLife takes a built-in profile name, a custom profile from next.config.ts, or an inline object. Three numbers control it: stale is how long the client holds a value without asking the server, revalidate is how often the server refreshes in the background, and expire is the ceiling after which the value stops being served at all.

With no cacheLife call at all you get the default profile, which is five minutes stale on the client and fifteen minutes revalidate on the server. That default is almost never what you want for tenant data.

// lib/usage.ts
import { cacheLife, cacheTag } from "next/cache";
import { tags } from "@/lib/cache-tags";
import { createClient } from "@supabase/supabase-js";

const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } },
);

export async function getUsageTotals(workspaceId: string) {
  "use cache";
  cacheLife({ stale: 60, revalidate: 120, expire: 900 });
  cacheTag(tags.usage(workspaceId));

  const { data, error } = await admin
    .from("usage_totals")
    .select("events, limit_events")
    .eq("workspace_id", workspaceId)
    .maybeSingle();

  if (error) throw new Error(`usage lookup failed: ${error.message}`);

  return {
    events: data?.events ?? 0,
    limit: data?.limit_events ?? 0,
  };
}

A rough allocation that holds up in practice: marketing and docs content gets cacheLife("max") plus a tag fired from your CMS webhook, tenant dashboards get minutes, billing state gets tagged and invalidated rather than timed, and anything an admin is actively editing stays dynamic behind Suspense until you have a reason to cache it.

Invalidating From an oRPC Mutation

An oRPC router mounted at app/api/rpc/[[...rest]]/route.ts is a Route Handler. That single fact decides which invalidation function you are allowed to call. updateTag is Server Actions only and throws anywhere else, so oRPC handlers use revalidateTag, which takes a tag and a profile.

Pass "max" for stale-while-revalidate behavior. The single argument form still runs but is deprecated, and the two argument form is what you want in new code.

// server/orpc/base.ts
import { os } from "@orpc/server";
import { revalidateTag } from "next/cache";

export interface AppContext {
  userId: string;
  workspaceId: string;
}

export const base = os.$context<AppContext>();

// Collect tags during the handler, flush them once it succeeds.
export const withInvalidation = base.middleware(async ({ next }) => {
  const pending = new Set<string>();

  const result = await next({
    context: {
      invalidate: (...tagList: string[]) => {
        for (const tag of tagList) pending.add(tag);
      },
    },
  });

  for (const tag of pending) {
    revalidateTag(tag, "max");
  }

  return result;
});

The middleware means a handler that throws never clears the cache, which is the behavior you want. A rename that fails validation should not blow away a warm entry.

// server/orpc/projects.ts
import { z } from "zod";
import { base, withInvalidation } from "./base";
import { tags } from "@/lib/cache-tags";
import { renameProjectRow } from "@/lib/projects-write";

export const renameProject = base
  .use(withInvalidation)
  .input(
    z.object({
      slug: z.string().min(1).max(64),
      name: z.string().min(1).max(80),
    }),
  )
  .handler(async ({ input, context }) => {
    const project = await renameProjectRow({
      workspaceId: context.workspaceId,
      slug: input.slug,
      name: input.name,
    });

    context.invalidate(
      tags.project(context.workspaceId, input.slug),
      tags.projects(context.workspaceId),
    );

    return project;
  });

The workspace id comes from context, which the route handler built from the verified session. It is never part of the Zod input schema. A client that could send its own workspace id could also invalidate, and read, another tenant's cache.

Read Your Own Writes in Server Actions

revalidateTag with "max" marks an entry stale and refreshes it in the background, so the very next render can still show the old value. For a form submit that redirects to the thing you just created, that reads as a bug.

Server Actions get updateTag, which expires the entry immediately so the next request blocks and fetches fresh data.

// app/(app)/projects/actions.ts
"use server";

import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { tags } from "@/lib/cache-tags";
import { createProjectRow } from "@/lib/projects-write";

export async function createProjectAction(formData: FormData) {
  const { workspaceId } = await getSession();
  const name = String(formData.get("name") ?? "").trim();

  if (name.length === 0) {
    throw new Error("Project name is required");
  }

  const project = await createProjectRow({ workspaceId, name });

  updateTag(tags.projects(workspaceId));
  updateTag(tags.project(workspaceId, project.slug));

  redirect(`/w/${workspaceId}/p/${project.slug}`);
}

So the split is simple. Server Action where the user is watching the result, use updateTag. oRPC handler or any other Route Handler, use revalidateTag(tag, "max").

Webhooks Need a Different Call

A Stripe webhook is a Route Handler, so updateTag is unavailable, and stale-while-revalidate is wrong because a downgraded plan should stop granting access on the next page load rather than a few minutes later.

For that case revalidateTag accepts an object instead of a profile name. Passing { expire: 0 } expires the entry immediately.

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { revalidateTag } from "next/cache";
import { tags } from "@/lib/cache-tags";
import { applySubscriptionChange } from "@/lib/billing";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return new Response("Missing signature", { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (
    event.type === "customer.subscription.updated" ||
    event.type === "customer.subscription.deleted"
  ) {
    const subscription = event.data.object as Stripe.Subscription;
    const workspaceId = subscription.metadata.workspaceId;

    if (workspaceId) {
      await applySubscriptionChange(workspaceId, subscription);
      revalidateTag(tags.billing(workspaceId), { expire: 0 });
      revalidateTag(tags.usage(workspaceId), { expire: 0 });
    }
  }

  return Response.json({ received: true });
}

Stamp workspaceId into the subscription metadata when you create the checkout session. Without it the webhook has no idea whose cache to clear.

What Should Never Go in a Cache

Some values do not belong behind "use cache" no matter how tempting the speed is.

Session identity is first. Next.js already blocks cookies() and headers() inside a cached scope, and if you try to smuggle the cookie store in as a prop the build hangs and then fails with a cache fill timeout after fifty seconds. That error message is telling you the design is wrong, not that you need a longer timeout.

Entitlement checks are second. Whether this workspace may access a feature should be evaluated per request against live billing state. Cache the expensive product data behind the check, not the check itself.

There is an escape hatch, "use cache: private", which does allow cookies(), headers(), and searchParams. Results are held only in browser memory and never written to the server cache, and it is not available in Route Handlers. The Next.js docs currently mark it experimental and not recommended for production, so treat it as a compliance workaround rather than a performance tool.

Teaching Claude Code the Rules

Claude will reach for Next.js 14 and 15 patterns unless you say otherwise, because there is far more unstable_cache and export const revalidate in the world than there is Cache Components code. A short block in CLAUDE.md fixes that for every session.

## Caching

- cacheComponents is on. Never use unstable_cache, export const revalidate,
  or export const dynamic. Use the "use cache" directive.
- Every cached function takes workspaceId as its first argument and filters
  on workspace_id in the query. RLS does not apply inside "use cache".
- Tags come from lib/cache-tags.ts. Never write a tag string inline.
- oRPC handlers and Route Handlers: revalidateTag(tag, "max").
- Server Actions where the user sees the result: updateTag(tag).
- Stripe and other webhooks: revalidateTag(tag, { expire: 0 }).
- Never read cookies(), headers(), or searchParams inside "use cache".
  Read them in the caller and pass values down as props.
- Dynamic regions go inside <Suspense> so the static shell still prerenders.

With that in place a request like "add a members list to the dashboard, cached per workspace, invalidated when an invite is accepted" produces the cached component, the tag helper entry, and the revalidateTag call in the oRPC handler without further prompting.

Quality Gates Before You Ship

Cache bugs hide well, so check the wiring directly. The verbose cache log tells you what is being written and read.

NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev

Then audit every cached scope for a tenant argument. Any "use cache" in a tenant-facing file whose surrounding function signature has no workspaceId deserves a second look.

rg -n -B 4 '"use cache"' app components lib server

And the three commands that run before every commit, same as always.

npx tsc --noEmit
npm run lint
npm run build

The build output is worth reading here rather than skimming. Next.js prints each route's render strategy, so a page you expected to be a static shell showing up fully dynamic means an uncached await escaped a Suspense boundary.

What an Orchestrated Pipeline Looks Like

A single Claude Code session gets a cached component right. Shipping a product means getting every future one right too, including the ones added six months from now by someone who never read this page. That is what the $29 Code Kit is: a harness on top of Claude Code that plans a feature, builds it, evaluates the output, tests it, and runs quality gates before it lands, so zero type errors, zero lint errors, and a clean build are the exit condition instead of a habit you hope survives. It is $29 one time with no subscription, and it sits on top of Claude Code, which needs its own paid Anthropic plan.


The whole strategy fits in four rules. Static shell by default, "use cache" for anything a few minutes stale is fine for, <Suspense> for anything that must be fresh, and the workspace id in every cache key and every tag. Write those into CLAUDE.md once and the caching layer stops being the thing you are afraid to touch.

Posted by @speedy_devv

Continue in Workflow

  • Commerce agentique : comment construire une app que les agents IA peuvent payer
    Un guide en français simple du commerce agentique en 2026 : ce que font x402, ACP et le Machine Payments Protocol, plus un pas-à-pas d'un week-end pour livrer une API payante que les agents IA peuvent acheter.
  • Bonnes pratiques Claude Code
    Cinq habitudes séparent les ingénieurs qui livrent avec Claude Code : les PRDs, les règles CLAUDE.md modulaires, les slash commands personnalisés, les resets /clear, et un état d'esprit d'évolution du système.
  • Le mode auto de Claude Code
    Un second modèle Sonnet examine chaque appel d'outil Claude Code avant qu'il s'exécute. Ce que le mode auto bloque, ce qu'il autorise, et les règles d'autorisation qu'il place dans tes paramètres.
  • Channels, Routines, Teleport, Dispatch
    Les quatre fonctionnalités Claude Code livrées par Anthropic en mars et avril 2026 qui transforment le CLI en une couche de coordination orientée événements, entre téléphone, web et desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Principes de base de l'agent
    Cinq façons de construire des agents spécialisés dans le code Claude : Sous-agents de tâches, .claude/agents YAML, commandes slash personnalisées, personas CLAUDE.md, et invites de perspective.
  • L'ingénierie du harness agent
    Le harness, c'est toutes les couches autour de ton agent IA sauf le modèle lui-même. Découvre les cinq leviers de contrôle, le paradoxe des contraintes, et pourquoi le design du harness détermine les performances de l'agent bien plus que le modèle.
  • Patterns d'agents
    Orchestrateur, fan-out, chaîne de validation, routage par spécialiste, raffinement progressif, et watchdog. Six formes d'orchestration pour câbler des sub-agents Claude Code.
  • Meilleures pratiques des équipes d'agents
    Patterns éprouvés pour les équipes d'agents Claude Code. Prompts de création riches en contexte, tâches bien calibrées, propriété des fichiers, mode délégué, et correctifs v2.1.33-v2.1.45.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

Max Plan vs API

Claude Code max plan vs api cost, decided by math. A Max 5x subscription beats an API key once you spend about $3.33/day; Max 20x breaks even at $6.67/day.

In-App Notifications

Build a full notification system for your SaaS: a notifications table with RLS, Inngest fan-out on events, a realtime unread badge over Supabase channels, per-user notification preferences, and a daily email digest through Resend.

On this page

Turn On Cache Components
Three Kinds of Content on One Page
The Leak Nobody Catches in Review
Verify the Tenant Before It Becomes a Cache Key
A Tag Vocabulary You Write Once
Picking a Cache Life
Invalidating From an oRPC Mutation
Read Your Own Writes in Server Actions
Webhooks Need a Different Call
What Should Never Go in a Cache
Teaching Claude Code the Rules
Quality Gates Before You Ship
What an Orchestrated Pipeline Looks Like

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →