Most Next.js tutorials stop at revalidate = 60 and call it done. Building NovaShop, I could not - an e-commerce app has public catalog pages, per-user carts, and auth all on the same App Router, and each one wants a different caching strategy. What follows is what I ended up doing on purpose, including the parts Next.js documents poorly across the 15 → 16 transition.
1. Three cache layers, kept explicitly separate
The App Router does not have one cache. It has three, and conflating them is where most stale-data bugs come from. I keep a single constants file that doubles as a map of which layer owns which data:
// lib/segment-config.ts
// Full Route Cache (route segment config) - complements the
// fetch() Data Cache and revalidateTag/revalidatePath/refresh().
export const CATALOG_REVALIDATE_SECONDS = 60;
export const AUTH_DYNAMIC = "force-dynamic" as const;
export const AUTH_FETCH_CACHE = "default-no-store" as const;- Full Route Cache - export const revalidate / dynamicParams. Product pages: revalidate = 60 (ISR).
- Data Cache - fetch() + tags, invalidated with revalidateTag. Keyed by CACHE_TAGS.product(id) and CACHE_TAGS.cart.
- Router Cache (client) - router.refresh(), called by refreshShopRoute() after a mutation.
A real constraint hides here: these config exports have to be literal `export const` in each page, layout, or route file. Next.js reads them statically at build time, so you cannot import a value from another module and re-export it. The constants file documents intent; the literal still has to live in the page.
2. Cache tags are hierarchical, not loose strings
export const CACHE_TAGS = {
products: "products",
catalog: "catalog",
product: (id) => `product-${id}`,
cart: "cart",
cartUser: (userId) => `cart-user-${userId}`,
} as const;When a cart changes, I invalidate both the shared tag and the per-user tag:
invalidateDataCacheTag(CACHE_TAGS.cart, source);
if (userId) {
invalidateDataCacheTag(CACHE_TAGS.cartUser(userId), source);
}This is the shape that avoids over-invalidation. Editing user A's cart must not purge user B's cart, but it still has to purge the aggregate tag behind things like a public mini-cart count. One flat tag would force you to choose between stale data and purging too much.
3. Feature-detecting the cache API across Next 15 and 16
This is the part I am most glad I wrote defensively. Rather than hardcode a Next.js version, the invalidation layer detects which cache API the runtime actually exposes:
function getNextCacheExtensions(): NextCacheExtensions {
try {
return require("next/cache") as NextCacheExtensions;
} catch {
return {};
}
}
function invalidateDataCacheTag(tag: string, source: RevalidateSource) {
const { updateTag } = getNextCacheExtensions();
const profile = source === "handler" ? { expire: 0 } : "max";
if (source === "action" && updateTag) {
updateTag(tag); // Next 16: synchronous invalidation within the request
return;
}
revalidateTagCompat(tag, profile); // Next 15 fallback
}Next.js 16 introduced updateTag(), which invalidates a tag and surfaces fresh data inside the current response - unlike revalidateTag, which only marks the entry stale for the next request. Detecting the API instead of pinning a version meant this code survived a Next.js major upgrade without a rewrite of the cache logic.
4. Route segment config working with server actions
// app/(shop)/products/[slug]/page.tsx
export const revalidate = 60;
export const dynamicParams = true;
export async function generateStaticParams() {
return getAllProductSlugParams();
}
export async function generateMetadata({ params }) {
const { slug } = await params; // params is a Promise - Next 15 async APIs
const id = productIdFromSlug(slug);
const data = await getProductById(id, { authenticated: false });
return buildPageMetadata({ title: data.name });
}dynamicParams = true lets a slug that is not in generateStaticParams() still render on demand and then cache - so adding a product to the catalog does not require rebuilding the whole site. Paired with next/dynamic to lazy-load the specs tab (ProductTabs), the splitting happens at the component level, not only the route level.
5. Middleware that refreshes tokens but dodges the crawler
The middleware intercepts requests to refresh the JWT access token when it is close to expiry:
const needsRefresh =
refreshToken && (!accessToken || isAccessTokenExpired(expiresAt));
if (!needsRefresh) return NextResponse.next();
// ...fetch a new token, reset the three cookies...The detail worth copying is the matcher - it excludes robots.txt and sitemap.xml on purpose:
export const config = {
matcher: [
// exclude metadata routes so Googlebot never hits the token refresh
"/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|manifest.json|.*\\..*).*)",
],
};Middleware runs on every request, including a crawler fetching your sitemap. A heavy middleware that calls the backend on the SEO crawl path wastes resources and can slow or break indexing. Excluding the metadata routes keeps the crawler on a fast path.
6. Server actions honouring the useActionState contract
"use server";
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
try {
await signIn("credentials", { email: formData.get("email") });
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return "Invalid credentials.";
default:
return "Something went wrong.";
}
}
throw error; // re-throw so signIn()'s redirect logic still runs
}
}The (prevState, formData) signature matches the client-side useActionState API exactly. The part that bites beginners: only AuthError is caught to return a message. Everything else - including the NEXT_REDIRECT that signIn() throws to navigate - is re-thrown. Swallowing that redirect error is the classic bug when you first pair Server Actions with NextAuth v5.
The point was not using more Next.js features - it was using the right cache layer for each kind of data, and writing defensively against an API that is still evolving.
That last part is the honest lesson. None of this design was obvious up front. It is the kind of structure you only arrive at after stale cache has bitten you in production at least once.