nextjs-cache-architecture

Use this skill whenever the user wants to design or implement caching in a Next.js 16+ App Router project — setting up the "use cache" directive, building a…

INSTALLATION
npx skills add https://github.com/mohamed-hossam1/nextjs-cache-architecture --skill nextjs-cache-architecture
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Next.js Cache Architecture

Architect caching in a Next.js 16+ App Router project from day one — not just

dropping "use cache" where it happens to fit, but structuring the tag

registry, revalidation utilities, Suspense boundaries, and mutation wiring so

the cache stays correct as the codebase grows.

How to use this skill

Apply every rule and template below to the user's actual project. Replace

placeholders like [Entity] and [collection] with names from their codebase

before writing any code.

$ARGUMENTS

Where to look next

Most implementations only need this file. Load a reference when the task

calls for it.

If the user is...

Read

Asking how cache keys are derived, what cacheLife profiles mean, or hitting a "use cache" limitation

references/core-concepts.md

Caching anything that depends on a logged-in user

references/personalized-content.md

Reporting stale data, or doing a final review pass

references/debugging-and-checklist.md

Migrating an existing codebase off unstable_cache

references/migration-from-unstable-cache.md

Drop-in templates in assets/ (rename placeholders to match the user's

codebase):

  • assets/tags.tslib/cache/tags.ts
  • assets/revalidate.tslib/cache/revalidate.ts
  • assets/SuspenseOnSearchParams.tsxcomponents/SuspenseOnSearchParams.tsx

The architecture in one breath

A correct cache implementation has three load-bearing pieces. Build all three

on day one — adding them later is much harder than getting them right up

front.

  • Tag registry (lib/cache/tags.ts) — every tag string lives here. No

raw strings anywhere else.

  • Revalidation utilities (lib/cache/revalidate.ts) — every

updateTag() lives here. Mutations import from this file.

  • Cache placement on data, not on pages"use cache" goes on

data-fetching functions or cached child components. Page components

orchestrate Suspense boundaries; the children fetch.

Once those three are in place, the rest is just applying them consistently.

Step 1 — Enable Cache Components

// next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {

  cacheComponents: true,

};

export default nextConfig;

Step 2 — Build the cache tag registry

File: lib/cache/tags.ts (template: assets/tags.ts)

Use the assets/tags.ts template. The as const satisfies TagRegistry shape

gives literal types and rejects malformed entries at compile time.

// lib/cache/tags.ts (skeleton — full template in assets/tags.ts)

export const CACHE_TAGS = {

  // Collection tags — one per logical data group, always present.

  [collection]: "[collection]",

  // Entity tag factories — only when a mutation targets a single entry.

  [entity]: (id: string | number) => `[entity]:${id}`,

} as const;

Step 3 — Build revalidation utilities

File: lib/cache/revalidate.ts (template: assets/revalidate.ts)

All updateTag() calls live here. Mutations import these functions — they

never call updateTag() directly.

// lib/cache/revalidate.ts

"use server";

import { updateTag } from "next/cache";

import { CACHE_TAGS } from "./tags";

function updateTags(tags: string[]) {

  for (const tag of tags) updateTag(tag);

}

// Bulk — any entry in the collection changed.

export async function revalidate[Collection]Cache() {

  updateTags([CACHE_TAGS.[collection]]);

}

// Surgical — one specific entry changed.

// Only write this if `CACHE_TAGS.[entity]` factory exists in the registry.

export async function revalidate[Entity]Cache(id: string | number) {

  updateTags([

    CACHE_TAGS.[collection], // always invalidate the parent collection too

    CACHE_TAGS.[entity](id),

  ]);

}

Step 4 — Implement data fetching

Place "use cache" in data-fetching functions. Never fetch inside page

components — page components orchestrate, they do not fetch.

// lib/data/[domain].ts

import { cacheLife, cacheTag } from "next/cache";

import { CACHE_TAGS } from "@/lib/cache/tags";

const BASE_URL = process.env.API_BASE_URL!;

// Good: collection fetch.

export async function get[Collection]() {

  "use cache";

  cacheLife("hours");

  cacheTag(CACHE_TAGS.[collection]);

  const res = await fetch(`${BASE_URL}/[endpoint]`);

  return res.json();

}

// Good: entity fetch.

export async function get[Entity](id: string) {

  "use cache";

  cacheLife("hours");

  cacheTag(CACHE_TAGS.[collection]);

  // Add CACHE_TAGS.[entity](id) only if a mutation calls updateTag on this entry.

  const res = await fetch(`${BASE_URL}/[endpoint]/${id}`);

  return res.json();

}
// Bad: fetching in a page component bypasses caching and invalidation.

export default async function Page() {

  const res = await fetch("/api/items");

  const data = await res.json();

  return <View data={data} />;

}

Step 5 — Structure rendering boundaries

Every page follows this shape:

Page component (sync, orchestration only — no data fetching)

  ├── Static shell (layout, nav — no data)

  ├── <Suspense> → cached shared content

  └── <Suspense> → dynamic personalized content

Standard page

// app/[route]/page.tsx

import { Suspense } from "react";

import { cacheLife, cacheTag } from "next/cache";

import { CACHE_TAGS } from "@/lib/cache/tags";

import { get[Collection] } from "@/lib/data/[domain]";

export default function AnyPage() {

  return (

    <>

      <StaticShell />

      <Suspense fallback={<SharedSkeleton />}>

        <SharedContent />

      </Suspense>

      <Suspense fallback={<PersonalizedSkeleton />}>

        <PersonalizedSection />

      </Suspense>

    </>

  );

}

async function SharedContent() {

  "use cache";

  cacheLife("hours");

  cacheTag(CACHE_TAGS.[collection]);

  const data = await get[Collection]();

  return <[Collection]List data={data} />;

}

Dynamic route page

// app/[domain]/[id]/page.tsx

import { Suspense } from "react";

import { cacheLife, cacheTag } from "next/cache";

import { CACHE_TAGS } from "@/lib/cache/tags";

import { get[Entity] } from "@/lib/data/[domain]";

export default function EntityPage({

  params,

}: {

  params: Promise<{ id: string }>;

}) {

  return (

    <Suspense fallback={<EntitySkeleton />}>

      <EntityDetail params={params} />

    </Suspense>

  );

}

async function EntityDetail({

  params,

}: {

  params: Promise<{ id: string }>;

}) {

  const { id } = await params;

  return <CachedEntityView id={id} />;

}

async function CachedEntityView({ id }: { id: string }) {

  "use cache";

  cacheLife("hours");

  cacheTag(CACHE_TAGS.[collection]);

  // Add CACHE_TAGS.[entity](id) only if a mutation needs surgical invalidation.

  const item = await get[Entity](id);

  return <[Entity]View item={item} />;

}

Filtered / search params page

// app/[route]/page.tsx

import { cacheLife, cacheTag } from "next/cache";

import { CACHE_TAGS } from "@/lib/cache/tags";

import { get[Collection]ByFilter } from "@/lib/data/[domain]";

import SuspenseOnSearchParams from "@/components/SuspenseOnSearchParams";

export default function FilteredPage({

  searchParams,

}: {

  searchParams: Promise<Record<string, string>>;

}) {

  return (

    <SuspenseOnSearchParams fallback={<FilteredListSkeleton />}>

      <FilteredList searchParams={searchParams} />

    </SuspenseOnSearchParams>

  );

}

async function FilteredList({

  searchParams,

}: {

  searchParams: Promise<Record<string, string>>;

}) {

  "use cache";

  cacheLife("minutes");

  cacheTag(CACHE_TAGS.[collection]);

  // searchParams is an argument → auto-keyed per unique param combination.

  const { q = "", page = "1" } = await searchParams;

  return await get[Collection]ByFilter(q, page);

}

A standard <Suspense> does not re-trigger its fallback on client-side

navigation when only searchParams changes. Use SuspenseOnSearchParams

(template: assets/SuspenseOnSearchParams.tsx) on every page with search or

filter params.

Step 6 — Handle personalized content

Read cookies() / headers() / auth() outside the cache boundary and

pass the value as a prop. The argument becomes part of the auto-generated

cache key, so each user gets their own entry. Calling any of those APIs

inside a "use cache" function throws or produces wrong behavior.

See references/personalized-content.md for the full read-outside / cache-inside

pattern and the rare "use cache: private" exception.

Step 7 — Wire mutations to invalidation

Mutations call revalidation utilities and never reach for updateTag()

themselves. This keeps the cache layer mechanical and auditable from one

file, and lets you add observability (logging, tracing) in one place.

// app/actions/[domain].ts

"use server";

import {

  revalidate[Collection]Cache,

  revalidate[Entity]Cache,

} from "@/lib/cache/revalidate";

export async function create[Entity](payload: unknown) {

  await db.[entity].create(payload);

  await revalidate[Collection]Cache();

}

export async function update[Entity](id: string | number, payload: unknown) {

  await db.[entity].update(id, payload);

  await revalidate[Entity]Cache(id); // requires the surgical utility to be exported

}

updateTag vs revalidateTag

Two APIs for two different needs:

API

Effect

Call from

updateTag(tag)

Immediate — the same request sees fresh data

Server actions, via revalidate.ts

revalidateTag(tag, "max")

Background stale-while-revalidate — next request sees fresh data

Route handlers, webhooks

revalidateTag always takes a second argument ("max" for

stale-while-revalidate, { expire: 0 } for immediate hard expiry). The

single-argument form is deprecated and silently does nothing in some

configurations.

Common mistakes

When the cache misbehaves, walk these in order. The first six catch nearly

everything; only run next build after the rest pass. The full debug walk

and a sign-off checklist are in references/debugging-and-checklist.md.

Symptom or smell

Fix

Function runs uncached on every request

"use cache" is after an await — move it to be the first statement.

Cached function throws or returns wrong data per user

Move cookies() / headers() / auth() outside; pass values as arguments.

updateTag does nothing

Tag string typo, or no cacheTag ever registered the matching tag.

Mutation completes but the list still reads stale

Revalidation utility called before the write, or not called at all.

Whole page re-renders even though only one section changed

A dynamic child sits inside a cached parent — split with <Suspense>.

Filter UI doesn't show a loading state on navigation

Plain <Suspense> — switch to SuspenseOnSearchParams.

Page marked dynamic when you expected static

Run next build; trace the leaked dynamic API in the route's source tree.

Page component fetches data directly

Move the fetch into a cached child; pages should orchestrate, not fetch.

For the full debug walk and a sign-off checklist, see

references/debugging-and-checklist.md. To verify the static parts of a

finished implementation against the user's project, run

scripts/audit.mjs <project-root> — usage and what it checks are documented

in README.md.

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card