Phần lớn tutorial Next.js dừng ở revalidate = 60 là xong. Khi làm NovaShop tôi không thể dừng ở đó - một app thương mại điện tử có trang catalog công khai, giỏ hàng theo từng user, và auth cùng nằm trên một App Router, mỗi thứ lại cần một chiến lược cache khác nhau. Dưới đây là những gì tôi làm một cách có chủ đích, kể cả phần Next.js document chưa tốt trong giai đoạn chuyển 15 → 16.
1. Ba tầng cache tách bạch tường minh, không trộn lẫn
App Router không có một cache duy nhất. Nó có ba, và trộn lẫn chúng chính là nơi phần lớn lỗi dữ liệu cũ sinh ra. Tôi giữ một file hằng số, đồng thời là bản đồ tư duy về việc tầng nào quản dữ liệu nào:
// lib/segment-config.ts
// Full Route Cache (route segment config) - bổ sung cho
// fetch() Data Cache và 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. Trang sản phẩm: revalidate = 60 (ISR).
- Data Cache - fetch() + tag, invalidate bằng revalidateTag. Khóa theo CACHE_TAGS.product(id) và CACHE_TAGS.cart.
- Router Cache (client) - router.refresh(), gọi qua refreshShopRoute() sau mỗi mutation.
Có một giới hạn thật nằm ở đây: các config export này phải là literal `export const` trong từng file page, layout hoặc route. Next.js đọc chúng tĩnh tại build time, nên không thể import một biến từ module khác rồi re-export. File hằng số ghi lại ý định; còn literal vẫn phải nằm trong chính page.
2. Cache tag phân cấp, không phải chuỗi rời rạc
export const CACHE_TAGS = {
products: "products",
catalog: "catalog",
product: (id) => `product-${id}`,
cart: "cart",
cartUser: (userId) => `cart-user-${userId}`,
} as const;Khi giỏ hàng thay đổi, tôi invalidate cả tag chung lẫn tag theo user:
invalidateDataCacheTag(CACHE_TAGS.cart, source);
if (userId) {
invalidateDataCacheTag(CACHE_TAGS.cartUser(userId), source);
}Đây là cấu trúc tránh over-invalidation. Sửa giỏ của user A không được purge cache giỏ của user B, nhưng vẫn phải purge tag tổng hợp đứng sau những thứ như mini-cart count hiển thị công khai. Một tag phẳng duy nhất sẽ buộc bạn phải chọn giữa dữ liệu cũ và purge quá tay.
3. Feature-detect API cache giữa Next 15 và 16
Đây là đoạn tôi mừng nhất vì ngay từ đầu đã viết phòng thủ. Thay vì hardcode phiên bản Next.js, lớp invalidate tự dò xem runtime thực sự expose API cache nào:
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: invalidate đồng bộ trong cùng request
return;
}
revalidateTagCompat(tag, profile); // Next 15 fallback
}Next.js 16 giới thiệu updateTag() - invalidate một tag và thấy dữ liệu mới ngay trong response hiện tại, khác với revalidateTag cũ chỉ đánh dấu stale cho lần sau. Dò API thay vì ghim phiên bản nghĩa là đoạn code này sống sót qua một lần Next.js nâng major version mà không phải viết lại toàn bộ logic cache.
4. Route segment config phối hợp với server action
// 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 là Promise - Next 15 async APIs
const id = productIdFromSlug(slug);
const data = await getProductById(id, { authenticated: false });
return buildPageMetadata({ title: data.name });
}dynamicParams = true cho phép một slug chưa nằm trong generateStaticParams() vẫn render on-demand rồi cache lại - nên thêm sản phẩm mới vào catalog không cần rebuild toàn site. Kết hợp với next/dynamic để lazy-load tab thông số kỹ thuật (ProductTabs), việc code-splitting diễn ra ở cấp component chứ không chỉ cấp route.
5. Middleware refresh token nhưng biết né crawler
Middleware chặn mọi request để refresh JWT access token khi gần hết hạn:
const needsRefresh =
refreshToken && (!accessToken || isAccessTokenExpired(expiresAt));
if (!needsRefresh) return NextResponse.next();
// ...fetch token mới, set lại 3 cookie...Chi tiết đáng học nằm ở matcher - nó loại trừ robots.txt và sitemap.xml một cách có chủ đích:
export const config = {
matcher: [
// loại metadata route để Googlebot không dính token refresh
"/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|manifest.json|.*\\..*).*)",
],
};Middleware chạy trên mọi request, kể cả một bot đang crawl sitemap. Một middleware nặng, gọi fetch tới backend, nằm trên đường crawl SEO vừa tốn tài nguyên vừa có thể làm chậm hoặc lỗi index. Loại các metadata route giúp crawler đi đường nhanh.
6. Server action theo đúng contract useActionState
"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 để redirect logic của signIn() vẫn chạy
}
}Chữ ký (prevState, formData) khớp đúng API useActionState phía client. Chỗ hay làm người mới vấp: chỉ bắt AuthError để trả message, còn lại - kể cả NEXT_REDIRECT mà signIn() throw ra để điều hướng - đều được throw tiếp. Nuốt nhầm lỗi redirect đó là bug kinh điển khi mới ghép Server Actions với NextAuth v5.
Cái hay không phải là dùng nhiều tính năng Next.js - mà là dùng đúng tầng cache cho đúng loại dữ liệu, và viết phòng thủ trước một API còn đang tiến hóa.
Phần cuối đó mới là bài học thật. Không thiết kế nào ở đây là hiển nhiên từ đầu. Đây là kiểu cấu trúc chỉ có được sau khi đã bị cache stale hành ít nhất một lần ngoài production.