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.
設定をやめて、構築を始めよう。
AIオーケストレーション付きSaaSビルダーテンプレート。
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 devThen 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 serverAnd the three commands that run before every commit, same as always.
npx tsc --noEmit
npm run lint
npm run buildThe 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
設定をやめて、構築を始めよう。
AIオーケストレーション付きSaaSビルダーテンプレート。
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.