Revalidation และ Cache Invalidation
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”เมื่อ data อยู่ใน Data Cache แล้ว เรา invalidate ได้สองทาง time-based (ปล่อยให้ stale หลัง N วินาที) หรือ on-demand (revalidateTag / revalidatePath หลัง mutation) และทั้งคู่ยัง refresh Full Route Cache ของ route ที่เกี่ยวข้องด้วย
Time-based revalidation
หัวข้อที่มีชื่อว่า “Time-based revalidation”ตั้ง lifetime ให้ fetch ที่ cache ไว้ด้วย next: { revalidate: N } หรือตั้งทั้ง route segment ด้วย export const revalidate เมื่อผ่านช่วงเวลานั้น request ถัดไปจะ trigger การ refresh แบบ background
// Per fetch: this data is at most 60 seconds staleawait fetch('https://api.example.com/posts', { next: { revalidate: 60 } })// app/blog/page.tsx — revalidate the entire segment every hourexport const revalidate = 3600On-demand revalidation ด้วย tag
หัวข้อที่มีชื่อว่า “On-demand revalidation ด้วย tag”สำหรับ data ที่เปลี่ยนตาม event ไม่ใช่ตามนาฬิกา ให้ tag fetch ไว้ แล้วเรียก revalidateTag(tag, profile) หลัง mutation ทุก fetch ที่ถือ tag นั้นจะถูก mark ว่า stale ใน Data Cache argument ตัวที่สองจำเป็นใน Next.js 16 (แบบ argument เดียวถูก deprecate และเป็น TypeScript error) — มันคือ profile ของ cacheLife ที่บอกว่ายอมเสิร์ฟ data stale ได้นานแค่ไหนระหว่างที่ refresh อยู่ ค่าที่แนะนำคือ 'max' ถ้าผู้ใช้ต้องเห็นสิ่งที่ตัวเองเพิ่งเขียนทันที ให้เรียก updateTag(tag) จาก Server Action แทน มันจะ expire entry ทำให้ request ถัดไปรอ data สด
export async function getPosts() { const res = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] }, }) return res.json()}'use server'import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) { await fetch('https://api.example.com/posts', { method: 'POST', body: JSON.stringify({ title: formData.get('title') }), }) // Mark every fetch tagged 'posts' stale; 'max' = serve stale while refreshing in the background revalidateTag('posts', 'max')}revalidatePath vs. revalidateTag
หัวข้อที่มีชื่อว่า “revalidatePath vs. revalidateTag”ทั้งคู่รันใน Server Action หรือ Route Handler revalidateTag เล็งไปที่ data ตาม tag ไม่ว่า fetch จากที่ไหน revalidatePath เล็งไปที่ route และเคลียร์ทุกอย่างที่ cache ไว้ให้ route นั้น ใช้ตอนที่เราไม่รู้ tag
'use server'import { revalidatePath } from 'next/cache'
export async function updateProfile() { // Re-render and refetch everything for this exact route revalidatePath('/profile')}revalidatePath('/', 'layout'): blast radius
หัวข้อที่มีชื่อว่า “revalidatePath('/', 'layout'): blast radius”revalidatePath รับ parameter type เป็น 'page' หรือ 'layout' การส่ง '/' คู่กับ 'layout' คือ revalidation call เดี่ยว ๆ ที่กว้างที่สุดใน API นี้ เอกสารที่ bundle มาอธิบายว่ามัน purge Client Cache และ invalidate cached data ทั้งหมดให้ revalidate ตอนเข้าชมครั้งถัดไป — root layout ห่อทั้งแอปไว้ ดังนั้น blast radius ของมันคือทุก route ที่อยู่ข้างใต้ ใช้มันเฉพาะกับสิ่งที่กระทบทุกอย่างจริง ๆ (reindex เนื้อหาทั้งหมด, สลับ locale) ไม่ใช่เป็น call แบบ “เผื่อไว้ก่อน” หลัง mutation ธรรมดา
'use server'import { revalidatePath } from 'next/cache'
export async function purgeEverything() { // Invalidates the root layout — every page in the app revalidates on next visit, // and the Client Cache is purged immediately. The largest revalidatePath call there is. revalidatePath('/', 'layout')}On-demand revalidation จาก Route Handler ด้วย secret
หัวข้อที่มีชื่อว่า “On-demand revalidation จาก Route Handler ด้วย secret”revalidation ของ Server Action ถูก trigger จาก mutation ของ user เอง แต่ content ที่เปลี่ยน นอก แอปของเรา — CMS ที่ถูกแก้, webhook จาก payment provider — ต้องการ Route Handler ที่ใครก็ตามบน internet เรียกได้ในทางเทคนิค ถ้าไม่มีการเช็ก Route Handler นั้นก็คือ open invalidation endpoint ทางแก้มาตรฐาน ตรงจาก pattern on-demand revalidation ของเอกสาร คือ secret ที่แชร์กันไว้ ส่งมาเป็น query parameter แล้วเทียบกับ environment variable ฝั่ง server ก่อนทำอะไรทั้งนั้น:
import { revalidateTag } from 'next/cache'import { NextRequest, NextResponse } from 'next/server'
// Called by a CMS webhook: /api/revalidate?secret=...&tag=postsexport async function POST(request: NextRequest) { const secret = request.nextUrl.searchParams.get('secret') if (secret !== process.env.REVALIDATE_SECRET) { return NextResponse.json({ message: 'Invalid secret' }, { status: 401 }) }
const tag = request.nextUrl.searchParams.get('tag') if (!tag) { return NextResponse.json({ message: 'Missing tag' }, { status: 400 }) }
revalidateTag(tag, 'max') return NextResponse.json({ revalidated: true, tag, now: Date.now() })}CMS (หรือ webhook sender อะไรก็ตาม) ถูกตั้งค่าให้รู้ URL บวก secret REVALIDATE_SECRET อยู่แค่ใน environment ฝั่ง server เท่านั้น ไม่เคยอยู่ใน NEXT_PUBLIC_ variable ดังนั้นมันไม่มีทางไปถึง client bundle เลย ใครก็ตามที่เรียก endpoint โดยไม่มี secret ที่ถูกต้องจะได้ 401 และไม่มีอะไร revalidate
header x-nextjs-cache ยังมีอยู่ใน Next.js 16 ไหม?
หัวข้อที่มีชื่อว่า “header x-nextjs-cache ยังมีอยู่ใน Next.js 16 ไหม?”มี — เช็กด้วย curl -I กับ server ที่รันจาก next start ใน probe ของคอร์สนี้ บน App Router route ที่ static เต็มตัว ทั้งสอง caching model:
HTTP/1.1 200 OKx-nextjs-cache: HITx-nextjs-prerender: 1x-nextjs-stale-time: 300Cache-Control: s-maxage=31536000x-nextjs-cache (ค่า HIT / MISS / STALE / REVALIDATED ที่เอกสารมีไว้สำหรับ Pages Router ISR flow แบบเก่า) ยังถูกส่งมาบน App Router route ที่ prerender ใน Next.js 16.3.6 — มันไม่ได้ถูกถอดออก มันอยู่คู่กับสอง header ใหม่กว่า: x-nextjs-prerender (บอกว่า response นี้เป็นแบบ prerendered/static) และ x-nextjs-stale-time (ช่วง stale ของ Client Cache หน่วยวินาที ในที่นี้ 300 ตรงกับ cacheLife profile default) ทั้งสามตัวโผล่มาเหมือนกันไม่ว่า cacheComponents จะเปิดหรือปิด
map ลงบน caching layer
หัวข้อที่มีชื่อว่า “map ลงบน caching layer”revalidation แตะสามในสี่ชั้น เริ่มจากทิ้ง entry ที่ match จาก Data Cache และเพราะผล render ของ route ขึ้นกับ data นั้น จึง mark Full Route Cache ที่เกี่ยวข้องให้ stale ด้วย request ถัดไปจะ render ใหม่ด้วย data สดและเติมทั้งสอง cache กลับ Client Cache ฝั่ง browser ก็ถูก invalidate ด้วย — ถ้าเรียกจาก Server Action revalidateTag / revalidatePath / updateTag จะล้างมันทันที ถ้าเรียกจาก Route Handler client จะรับรู้การเปลี่ยนแปลงตอน navigate ครั้งถัดไป มีแค่ Request Memoization ที่ไม่ถูกแตะ — มันเกิดและตายภายใน render เดียว
flowchart LR
A["Server Action: mutate"] --> B["revalidateTag('posts')"]
B --> C["Data Cache: drop tagged entries"]
C --> D["Full Route Cache: mark routes stale"]
D --> E["Next request re-renders with fresh data"]
B --> F["Client Cache: purged when called from a Server Action"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”revalidation call ทุกตัวตอบคำถามเดียวกัน — “cached entry ไหนผิดไปแล้วบ้าง?” — ด้วยความละเอียดคนละระดับ revalidateTag ตอบในแง่ data อะไร (ทุก entry ที่ถือ tag นั้น ไม่ว่าจะอยู่ที่ไหน) revalidatePath ตอบในแง่ route ไหน (ทุกอย่างที่ cache ไว้ให้ path นั้น ไม่สนใจ tag) revalidate แบบ time-based ตอบในแง่ นานแค่ไหน (นาฬิกา ไม่ใช่ event) ไม่มีตัวไหนลบ data ทันทีโดย default — revalidateTag/revalidatePath mark ว่า stale แล้ว refresh แบบ background มีแค่ updateTag (Server Action เท่านั้น) ที่ expire ทันทีเพื่อ read-your-own-write
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
revalidateTag(tag, 'max') | แม่นยำ — แตะแค่ entry ที่ถือ tag นั้นตรง ๆ | ต้องมีการ tag fetch ไว้ก่อน — tag ที่ลืมใส่มองไม่เห็นสำหรับ call นี้เลย |
revalidatePath('/blog/1') | ทำงานได้โดยไม่ต้องรู้ tag | revalidate แค่ path นั้นตรง ๆ — page พี่น้องที่ใช้ data เดียวกันยัง stale จนกว่า path ของมันเอง (หรือ tag ที่แชร์กัน) จะถูก revalidate |
revalidatePath('/', 'layout') | เรียกครั้งเดียว ทั้งแอปสดหมด | เป็นค้อนที่ใหญ่ที่สุดที่มี — purge Client Cache และ mark ทุก route ให้ revalidate ซึ่งสิ้นเปลืองมากสำหรับ mutation แค่ record เดียว |
| Route Handler ที่มี secret gate | ให้ระบบภายนอก (CMS, webhook) trigger revalidation ได้อย่างปลอดภัย | เพิ่มพื้นที่ operational — ต้อง provision secret, เก็บให้พ้นจาก NEXT_PUBLIC_, และหมุนเวียนถ้ารั่ว |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “revalidateTag ลบ cached data ทันที” — ไม่จริง (โดยตั้งใจ) — มัน mark entry ว่า stale แล้วเสิร์ฟค่าเดิมระหว่างที่ background refresh รัน นานสูงสุดตาม
cacheLifeprofile ที่ส่งเป็น argument ตัวที่สอง มีแค่updateTagเท่านั้นที่ expire ทันที และเรียกได้แค่จาก Server Action - “revalidatePath(‘/blog/1’) revalidate /blog/2 ด้วยถ้าแชร์ fetch tag เดียวกัน” — ไม่จริง
revalidatePathแตะแค่ path ตรง ๆ (หรือ pattern page/layout ที่ให้ไป) ไม่แตะ path อื่นที่บังเอิญใช้ data ที่ tag เดียวกันเลย นั่นคือหน้าที่ของ tag-based revalidation ตรง ๆ - “revalidatePath(’/’, ‘layout’) เป็น default ที่ปลอดภัยจะโรยไว้หลัง mutation ไหนก็ได้” — มันทำงานได้ก็จริง แต่เป็น revalidation call ที่ใหญ่ที่สุดที่มี — purge Client Cache และ mark ทั้งแอปให้ revalidate ใช้ call ที่แคบที่สุดที่ยังถูกต้องแทน
- “Route Handler สำหรับ revalidation ที่ไม่มีการเช็ก secret ก็โอเคเพราะ URL มันลึกลับ” — URL ที่ลึกลับไม่ใช่ authorization ใครก็ตามที่เจอหรือเดา URL ได้ก็ trigger revalidation ได้ (หรือแย่กว่านั้น denial-of-service ด้วยการ purge cache ซ้ำ ๆ) ถ้าไม่มี secret กันไว้
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ snippet ที่ generate มา revalidate cached data ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
revalidateTagได้ argument ครบสองตัวไหม? call ที่มี argument เดียวคือ TypeScript error ใน Next.js 16 (revalidateTagต้องการcacheLifeprofile เป็น argument ตัวที่สอง) — snippet ที่ generate มาจาก docs เก่าหรือ training data มักจะลืมใส่- Route Handler สำหรับ revalidation ขาดการเช็ก secret หรือเปล่า? Route Handler ไหนก็ตามที่เรียก
revalidateTag/revalidatePathต้อง verify secret ที่ caller ส่งมาเทียบกับ environment variable ฝั่ง server ก่อนทำอะไรทั้งนั้น revalidatePath('/', 'layout')ถูกใช้กับ mutation ธรรมดาแค่ record เดียวหรือเปล่า? นั่นคือ blast radius ที่ใหญ่ที่สุดที่เป็นไปได้ — ใช้ tag หรือ path เฉพาะเจาะจงแทน เว้นแต่การเปลี่ยนแปลงจะกระทบทั้งแอปจริง ๆ- โค้ดเรียก
updateTagจาก Route Handler หรือเปล่า?updateTagทำงานได้แค่ใน Server Action เท่านั้น Route Handler ต้องใช้revalidateTagแทน
🐛 หา bug ในโค้ดนี้
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {await fetch('https://api.example.com/posts', { method: 'POST', body: JSON.stringify({ title: formData.get('title') }),})revalidateTag('posts')}เฉลย
revalidateTag ถูกเรียกด้วย argument แค่ตัวเดียว ตั้งแต่ Next.js 16 มันต้องการ argument ตัวที่สอง — profile ของ cacheLife ที่บอกว่ายอมเสิร์ฟ data stale ได้นานแค่ไหนระหว่าง refresh พิสูจน์แล้วในโพรบ: tsc --noEmit กับ call นี้เป๊ะ ๆ รายงาน error TS2554: Expected 2 arguments, but got 1. — compile error จริง ไม่ใช่ lint warning ทางแก้คือ revalidateTag('posts', 'max') (หรือ profile ชื่ออื่น) ซึ่ง compile ผ่านสะอาด