ข้ามไปยังเนื้อหา

use cache และ Cache Components

Cache Components พลิกโมเดล แทนที่จะจำ cache option ต่อ fetch เรา mark function, file หรือ component ด้วย directive 'use cache' เพื่อบอกว่า “cache อันนี้” แล้วปรับ lifetime กับ invalidation ด้วย cacheLife และ cacheTag

ใส่ string 'use cache' ไว้บนสุดของ function หรือ file แล้วทุกอย่างที่ function นั้น return จะถูก cache ไม่ว่าจะเป็น fetch, query database หรือทั้ง component ที่ render แล้ว วิธีนี้มาแทนการโปรย cache option ตาม call ทีละอัน

lib/data.ts
import { cacheLife, cacheTag } from 'next/cache'
async function getData() {
'use cache'
cacheLife('hours')
cacheTag('data')
const res = await fetch('https://api.example.com/data')
return res.json()
}

โค้ดที่เขียนสำหรับ Next.js 14 (หรือ AI model ที่ train มาจากยุคนั้น) ยังใช้ unstable_cache — ห่อ function, ส่ง key-parts array, ส่ง options object เอกสารที่ bundle มาบอกตรง ๆ ว่า “This API has been replaced by use cache in Next.js 16” มันยังทำงานได้อยู่ (ไม่ได้ถูกลบ) แต่การ mapping นี้ควรจำให้ขึ้นใจ เพราะเจอได้ในการ review โค้ดเก่าแทบทุกครั้ง:

unstable_cacheสิ่งที่เทียบเท่าใน 'use cache'
unstable_cache(fn, keyParts, options) ห่อ function จากข้างนอก'use cache' อยู่ ข้างใน ตัว function เอง เป็นบรรทัดแรก
keyParts: ['posts'] — ส่วนต่อท้าย cache key ที่ตั้งเองไม่ต้องมี — 'use cache' derive key จาก argument และ closure ของ function เองอัตโนมัติ
options.tags: ['posts']cacheTag('posts') เรียกข้างในตัว function
options.revalidate: 3600 (วินาที)cacheLife({ revalidate: 3600 }) หรือ named profile อย่าง cacheLife('hours')
ทำงานได้แม้ cacheComponents ปิดอยู่ต้องการ cacheComponents: true
สถานะใน docs ของ Next.js 16Deprecated แล้ว แต่ยัง import ได้ — ไม่ได้ถูกลบ
lib/posts-old.ts
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
// Old model: keyParts is a manual cache-key suffix; tags/revalidate live in
// the options object.
export const getPosts = unstable_cache(
async () => db.post.findMany(),
['posts'],
{ tags: ['posts'], revalidate: 3600 },
)
lib/posts-new.ts
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
// New model: no manual key — 'use cache' derives it from the function's
// arguments and closure. Tag and lifetime are set inline, in scope.
export async function getPosts() {
'use cache'
cacheTag('posts')
cacheLife({ revalidate: 3600 })
return db.post.findMany()
}
lib/db.ts
export const db = {
post: {
findMany: async () => [] as { id: string; title: string }[],
},
}

cacheLife ตั้งว่า entry จะสดอยู่นานแค่ไหน เป็น named profile อย่าง 'hours' หรือ object ที่ระบุชัด Next.js มี named profile ให้หกแบบนอกจาก default แต่ละแบบสมดุลสามค่าเวลาเดียวกัน (stale, revalidate, expire):

ProfileUse casestalerevalidateexpire
defaultเนื้อหาทั่วไป5 นาที15 นาทีไม่มี
secondsdata แบบ real-time (ราคาหุ้น, สกอร์สด)30 วิ1 วิ1 นาที
minutesอัปเดตบ่อย (social feed, ข่าว)5 นาที1 นาที1 ชั่วโมง
hoursอัปเดตหลายครั้งต่อวัน (สต๊อกสินค้า, สภาพอากาศ)5 นาที1 ชั่วโมง1 วัน
daysอัปเดตรายวัน (blog post, บทความ)5 นาที1 วัน1 สัปดาห์
weeksอัปเดตรายสัปดาห์ (podcast, newsletter)5 นาที1 สัปดาห์30 วัน
maxแทบไม่เปลี่ยนเลย (หน้ากฎหมาย, เนื้อหา archive)5 นาที30 วัน1 ปี

คุณนิยาม named profile ของตัวเองใน next.config.ts ได้ด้วย ควบคู่หรือแทนที่ชื่อ built-in — ดู Custom cache profiles ด้านล่างสำหรับตัวอย่างเต็มที่ build และเช็กในโพรบแล้ว

cacheTag ติด tag เพื่อ invalidate entry แบบ on-demand ได้ และหนึ่ง call ติดได้มากกว่าหนึ่ง tag: cacheTag('products', 'product-42') ติด tag ให้ entry เดียวกันทั้งแบบกว้าง (สินค้าทุกชิ้น) และแบบเจาะจง (สินค้าชิ้นนี้) ทำให้ mutation invalidate ได้ในระดับความละเอียดที่เหมาะสม revalidateTag(tag, 'max') mark tag ว่า stale แล้วเสิร์ฟ entry เดิมระหว่าง refresh (ใน Next.js 16 argument ตัวที่สองจำเป็น แบบ argument เดียวถูก deprecate และเป็น TypeScript error) ส่วนใน Server Action updateTag(tag) จะ expire ทันทีเพื่อให้ผู้ใช้เห็นสิ่งที่ตัวเองเพิ่งเขียน

import { cacheLife, cacheTag } from 'next/cache'
async function getProduct(id: string) {
'use cache'
cacheTag('products', `product-${id}`)
cacheLife({ stale: 60, revalidate: 300 })
return db.product.findUnique({ where: { id } })
}

'use cache' ต้องใช้ flag cacheComponents ใน Next.js 16 flag ตัวเดียวกันนี้ ยัง เป็นวิธีเปิด Partial Prerendering ด้วย flag ppr แบบ experimental เดิมและ export experimental_ppr ถูกถอดออกไปแล้ว เมื่อเปิด flag นี้ Next.js จะ prerender shell แบบ static และ stream ส่วน dynamic ที่ไม่ cache เข้ามาตอน request

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig

ขยาย config file เดิมด้วย named cacheLife profile ของตัวเอง ควบคู่กับ flag:

// next.config.ts — extends the minimal config above with a custom profile
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
biweekly: {
stale: 60 * 60 * 24 * 14, // 14 days
revalidate: 60 * 60 * 24, // 1 day
expire: 60 * 60 * 24 * 14, // 14 days
},
},
}
export default nextConfig
// app/catalog/page.tsx — only type-checks against the config above, not the harness's shared probe config
import { cacheLife } from 'next/cache'
async function getData() {
'use cache'
cacheLife('biweekly')
return { now: Date.now() }
}
export default async function CatalogPage() {
const data = await getData()
return <p>{data.now}</p>
}

type overload ของ cacheLife สำหรับชื่อ profile ที่ตั้งเองถูก generate จาก next.config.ts ตัวไหนก็ตามที่อยู่จริงที่ root ของโพรบตอนรัน next dev/next build/next typegen — fence ในบทเรียนที่ชื่อ next.config.ts ไม่ได้เปลี่ยนอันนั้นให้ shared type-check harness เลย นี่คือเหตุผลที่คู่นี้ถูกพิสูจน์ด้วย next build จริงแบบแยกโพรบ (ด้านล่าง) แทนที่จะปล่อยให้ harness เก็บอัตโนมัติ

Build และเช็กในโพรบแล้ว build output ยืนยันว่าตัวเลขของ custom profile ผ่านเข้าไปจริง:

Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
└ ○ /catalog 1d 2w

มีสอง variant ที่ต่อยอดโมเดลนี้ ทั้งคู่มาพร้อม Cache Components ใน Next.js 16 'use cache: private' อ่าน cookies(), headers() และ searchParams ได้ตรง ๆ ผลลัพธ์ไม่ถูกเขียนลง shared cache ฝั่ง server เลย — มันถูกเก็บ ต่อ browser ใน memory ของ client ตลอดช่วง stale จึงไม่แชร์ข้ามผู้เข้าชม ส่วน 'use cache: remote' เก็บ entry ใน cache handler ที่แชร์และคงทน (Redis, KV หรืออะไรก็ตามที่ platform ให้ผ่าน cacheHandlers) ทำให้ทุก server instance และทุก user เจอ entry เดียวกัน แลกกับ network round trip

import { cacheLife } from 'next/cache'
import { cookies } from 'next/headers'
async function getRecommendations() {
'use cache: private'
cacheLife({ stale: 60 })
const sessionId = (await cookies()).get('session-id')?.value
return db.recommendations.forSession(sessionId)
}
flowchart TD
  A["cacheComponents: true"] --> B["'use cache' function or component"]
  A --> C["Partial Prerendering"]
  B --> D["Cached static shell"]
  C --> D
  D --> E["Dynamic uncached holes stream in"]
cacheComponents: static shell บวก dynamic hole ที่ stream เข้ามา

'use cache' ย้ายจุดตัดสินใจเรื่อง caching จาก ตำแหน่งที่เรียก (options object บน fetch แต่ละครั้ง) ไปที่ ตำแหน่งที่นิยาม (directive บนสุดของ function) พอเข้ามาอยู่ใน scope ของ 'use cache' แล้ว cacheLife กับ cacheTag คือ configuration ท้องถิ่นสำหรับหน่วย cache นั้นเดียว — มันไม่มีทางรั่วออกไป และไม่มีอะไรเกี่ยวกับวิธีที่มันถูกเรียกจากข้างนอกที่ต้องเปลี่ยน นั่นคือการพลิกโมเดลทั้งหมดจากแบบเดิม — caching กลายเป็นคุณสมบัติของ function ไม่ใช่ของแต่ละ call

สิ่งที่ได้ประโยชน์ต้นทุน
'use cache' บน functionไม่ต้องมี cache key เอง tag/lifetime อยู่ติดกับ logic ที่มันอธิบายต้องการ cacheComponents: true — flag แบบ all-or-nothing สำหรับ route segment config ที่มันมาแทน
Built-in cacheLife profileชื่อสื่อความหมาย ('days' อ่านแล้วรู้ว่า “อัปเดตรายวัน”)ตัวเลขจริงตายตัวเว้นแต่จะ override — อ่านตารางถ้าความแม่นยำสำคัญ
Custom cacheLife profile ใน next.config.tsคุมได้ชัดเจน ใช้ชื่อซ้ำได้ทั่วแอปมีอีกจุดที่ต้องดูแลให้ตรงกัน — ชื่อมีความหมายแค่ตามที่ config บอกไว้ตอนนั้น
cacheTag หลายค่าบน entry เดียวinvalidate แบบกว้าง ('products') หรือแบบแคบ ('product-42') จาก mutation คนละตัวทุก tag ที่เพิ่มมาคือสิ่งที่ต้อง revalidate ให้ถูกต้อง ไม่งั้นจะดูเหมือน invalidate แล้วทั้งที่ยังไม่ได้
  • “unstable_cache ถูกลบใน Next.js 16 แล้ว” — ไม่จริง มันยัง compile และรันได้อยู่ แค่เอกสาร mark ว่าถูกแทนด้วย 'use cache' และแนะนำให้ migrate
  • “'use cache' ต้องมี cache key เองเหมือน keyParts ของ unstable_cache” — ไม่จริง directive derive key จาก argument และ closure ของ function เองอัตโนมัติ
  • “เพิ่ม cacheTag ตัวที่สองจะแทนที่ตัวแรก” — ไม่จริง cacheTag('products', 'product-42') ติดทั้งสอง tag ให้ entry เดียวกัน ตัวไหนก็ invalidate มันได้ทีหลัง
  • “'use cache: private' เป็นแค่ cache ธรรมดาที่อายุสั้นกว่า” — ไม่ใช่ มันเป็นที่เก็บคนละที่เลย ไม่เคยเขียนลง shared cache ฝั่ง server เก็บต่อ browser แทน โดยเฉพาะเพื่อให้อ่าน cookies()/headers() ข้างในได้อย่างปลอดภัย

เมื่อ snippet ที่ generate มาใช้ unstable_cache หรือ 'use cache' ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • unstable_cache ถูกใช้โดยไม่มี tags ใน options object หรือเปล่า? ถ้าไม่มี tag จะไม่มีทางให้ revalidateTag เล็ง entry นั้นได้เลย — มันจะ expire ได้แค่ตาม timer ของ revalidate (หรือตอน redeploy ทั้งแอป) ซึ่งทำลาย on-demand invalidation แบบเงียบ ๆ
  • function 'use cache' เรียก cacheLife หรือเปล่า? การไม่ใส่ไม่ error — มันแค่ fallback ไป profile default (revalidate 15 นาที) แบบเงียบ ๆ ซึ่งเข้าใจผิดเป็น “ไม่ cache” ได้ง่าย
  • cacheTag ถูกเรียกด้วย tag เดียวทั้งที่ entry ต้องการสอง tag ตาม logic (tag ระดับ collection กับ tag ระดับ item) หรือเปล่า? cacheTag รับหลายค่าในหนึ่ง call ได้ ไม่ต้องเรียกสองครั้งหรือเลือกแค่ความละเอียดเดียว
  • โค้ดสมมติว่า 'use cache' ทำงานได้โดยไม่มี cacheComponents: true ใน next.config.ts หรือเปล่า? ไม่ได้ directive ต้องการ flag นี้ และ export แบบ route-segment-config เดิม (dynamic, revalidate, fetchCache) จะทำให้ build ล้มทันทีที่เปิด flag นี้

🐛 หา bug ในโค้ดนี้

import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
export const getPosts = unstable_cache(async () => {
return db.post.findMany()
})
เฉลย

ไม่มี tags ถูกส่งใน options object (ที่ถูกละไว้) และไม่มี keyParts array ด้วย เอกสารที่ bundle มาอธิบายว่า tags คือสิ่งที่ revalidateTag ใช้เล็ง entry — ถ้าไม่มี cached result นี้จะ stale ได้แค่ตาม timer ของตัวเอง (ในที่นี้ไม่มี revalidate เลยด้วย เลย cache ไปตลอด) หรือตอนแอป redeploy Server Action ที่เรียก revalidateTag('posts') หลังสร้าง post ใหม่ทีหลังจะไม่ทำอะไรกับ entry นี้เลย เพราะมันไม่เคยถูก tag ว่า 'posts' — bug ที่ไม่มี error ไม่มี warning มีแต่ data ค้างตลอดไป ทางแก้คือ unstable_cache(async () => db.post.findMany(), ['posts'], { tags: ['posts'] }) หรือดีกว่านั้นคือ migrate ไปเป็น 'use cache' พร้อม cacheTag('posts') ชัดเจน

directive use cache ทำอะไร?
flag ตัวไหนเปิดใช้ use cache?
อะไรตั้ง lifetime และ tag ของ entry use cache?
ใน Next.js 16 cacheComponents ยังเปิดอะไรอีก?
ถ้า migrate ไปเป็น 'use cache' คู่ call ไหนเทียบเท่ากับ options object ตรง ๆ?
export const getPosts = unstable_cache(
  async () => db.post.findMany(),
  ['posts'],
  { tags: ['posts'], revalidate: 3600 },
)
Sever Action ทีหลังเรียก revalidateTag('posts', 'max') หลังสร้าง post ใหม่ เกิดอะไรกับ cached entry นี้?
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

export const getPosts = unstable_cache(async () => {
  return db.post.findMany()
})