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

Partial Prerendering

Partial Prerendering เสิร์ฟ static shell ที่ prerender ไว้แล้วทันที และ stream “hole” ที่เป็น dynamic — แต่ละอันห่อด้วย Suspense — เข้ามาใน shell ตอน request ทำให้ route เดียวได้ทั้งความเร็วของ static และความสดของ dynamic

ก่อนมี PPR คุณต้องเลือก mode เดียวต่อ route — static ล้วน (เร็ว แต่ไม่มี data ต่อ request) หรือ dynamic ล้วน (สด แต่ไม่มีอะไร prerender) PPR ตัด “อย่างใดอย่างหนึ่ง” ทิ้ง ส่วนของหน้าที่ไม่เปลี่ยนเลยจะถูก prerender เป็น static shell ส่วนที่ต้องใช้ data ตอน request กลายเป็น dynamic hole แต่ละอันอยู่หลัง <Suspense> boundary แล้ว stream เข้ามาเมื่อ resolve

app/product/[id]/page.tsx
import { Suspense } from 'react';
import { cookies } from 'next/headers';
async function LivePrice() {
const store = await cookies();
const currency = store.get('currency')?.value ?? 'USD';
const price = await fetch('https://api.example.com/price', {
cache: 'no-store',
}).then((r) => r.json());
return <strong>{price.amount} {currency}</strong>;
}
export default function ProductPage() {
return (
<main>
<h1>Wireless Headphones</h1>
<p>This description is prerendered into the static shell.</p>
<Suspense fallback={<span>Loading price…</span>}>
<LivePrice />
</Suspense>
</main>
);
}

heading กับ description ออกไปทันทีเป็น static HTML ส่วน LivePrice คือ dynamic hole ที่ stream เข้ามาต่อ request

static shell ไม่ใช่แค่ content ที่อยู่นอก <Suspense> — มัน รวม fallback เข้าไปด้วย ตอน build Next.js render fallback UI แล้วฝังมันลงไปใน static HTML แทนที่ hole นั้น ต่อเมื่อมี request จริงเข้ามาเท่านั้น content แบบ dynamic จริงถึงจะเข้ามาแทนที่ build product page ด้านบนแล้วอ่าน legend จริงจาก next build ยืนยันว่า route นี้ไม่ใช่ static ล้วนหรือ dynamic ล้วน —

Route (app)
┌ ○ /
├ ○ /_not-found
└ ◐ /product/[id]
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content

◐ แปลว่าเป๊ะ ๆ แบบนี้ — ทุกอย่างที่อยู่นอก <Suspense> บวกกับ fallback ทุกตัว อยู่ใน static HTML ที่ส่งออกไปทันที ทุกอย่างที่อยู่ข้างใน boundary stream เข้ามาทีหลัง เพราะแบบนี้ content ของ fallback ถึงสำคัญจริง ๆ — ผู้เข้าชมที่ connection ช้าอาจจะเห็นมันอยู่พักหนึ่ง และ search crawler ที่รอ content แบบ stream ไม่ได้จะเห็น fallback เป็น state สุดท้ายเลย เว้นแต่มันจะ re-render แบบ dynamic

โค้ดบางอย่างไม่มี Dynamic API ให้ใช้ แต่ยังต้องรันต่อ request ไม่ใช่ครั้งเดียวตอน build — Math.random(), crypto.randomUUID() หรือ synchronous database driver สอง function จาก next/server / next/cache ทำให้โค้ดแบบนี้หลุดออกจาก static shell —

app/lib/visitor-count.ts
import { connection } from 'next/server';
// Stands in for any synchronous data source (a sync DB driver, an in-memory
// counter) that would otherwise complete during prerendering with no
// Dynamic API of its own to stop it.
declare function readVisitorCountSync(): number;
export async function getVisitorCount() {
await connection(); // prerendering stops here — the rest only runs at request time
return readVisitorCountSync();
}
app/components/CurrentTime.tsx
import { Suspense } from 'react';
import { io } from 'next/cache';
export default function CurrentTimeSection() {
return (
<Suspense fallback={<p>Loading…</p>}>
<CurrentTime />
</Suspense>
);
}
async function CurrentTime() {
await io(); // suspends during prerendering, so the fallback ships in the shell
return <p>{new Date().toISOString()}</p>;
}

connection() คือเครื่องมือเก่าและกว้างกว่า — มันรอ request จริงเข้ามาก่อนถึงจะทำงานต่อ เต็มรูปแบบ มันมาแทน unstable_noStore ส่วน io() คือทางเลือกเฉพาะของ Cache Components และ docs แนะนำให้ใช้เป็นตัวเลือก default ภายใต้ cacheComponents — มันทำงานแบบเดียวกัน แต่ค่าที่มันกันออกไปนั้น cache หรือ prefetch ได้ด้วย เพราะ io() ที่อยู่ใน scope 'use cache' เป็น no-op — ค่าจะถูก capture ลงใน static shell ตรงนั้นแทน ไม่ต้องมี <Suspense> boundary เลย ใช้ connection() เฉพาะตอนที่ rendering ต้องรอ user request จริง ๆ เท่านั้น ใช้ io() ที่เหลือทั้งหมดภายใต้ Cache Components

static shell ไม่ใช่แค่สิ่งที่ส่งออกไปตอนโหลดครั้งแรก — มันคือสิ่งที่ <Link> prefetch ล่วงหน้าก่อนคลิกด้วย ใน Next.js 16.3 นี่คือ config flag แยกต่างหากชัดเจน — partialPrefetching ซึ่งต้องมี cacheComponents เปิดอยู่แล้วก่อน

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

ถ้าไม่เปิด Next.js จะ prefetch เต็ม route ต่อ <Link> ที่มองเห็นหนึ่งตัว — หน้าที่มี link เยอะ ๆ ก็ทำให้เกิด prefetch request แยกกันหลายตัว พอเปิด partialPrefetching: true Next.js จะ prefetch App Shell ที่ใช้ซ้ำได้หนึ่งตัวต่อ route แชร์กันโดยทุก link ที่ชี้ไปที่นั่น แล้ว resolve content เฉพาะ URL (params, searchParams) หลังจากที่ navigate จริงเท่านั้น segment หนึ่งสามารถ override ค่า default ระดับแอปด้วย export prefetch ของตัวเอง ('auto' เป็น default, 'partial' หรือ 'force-disabled') —

app/product/[id]/page.tsx
export const prefetch = 'partial'; // opt this segment in without flipping the flag app-wide

partialPrefetching เพิ่งมาใน 16.3.0 — มันไม่มีอยู่ใน release ก่อนหน้าของ 16.x และเป็น flag แยกจาก cacheComponents เอง ไม่ใช่ค่าที่ส่งเข้าไปใน cacheComponents

โมเดลทั้งสามเรียงตัวบนแกนเดียว — ว่ารู้ route มากแค่ไหนตอน build —

  • static ล้วน — ทั้ง route ถูก prerender ไม่มีอะไรต่อ request เร็วที่สุด แต่ไม่มี data สด
  • dynamic ล้วน — ทั้ง route render ทุก request สดเสมอ แต่ไม่มีอะไร prerender TTFB จึงรอ data ที่ช้าที่สุด
  • PPR — shell (รวม fallback ทุกตัว) ถูก prerender เหมือน static และมีแค่ hole ที่เป็น dynamic first paint เร็ว และ มี data ต่อ request ใน route เดียว
flowchart LR
  A[Request] --> B[Static shell served instantly]
  B --> E[Shell prerendered at build time, fallback included]
  B --> C[Dynamic hole in Suspense]
  C --> D[Streamed at request time]
route แบบ PPR: static shell กับ dynamic hole ที่ stream เข้ามา

route แบบ PPR คือ brochure ที่พิมพ์ไว้ พร้อมจอเล็ก ๆ แบบ live ฝังอยู่ในนั้น brochure — ทุกตัวอักษรของมัน รวมถึง placeholder text ที่อยู่ในกรอบของจอนั้น — ถูกพิมพ์ครั้งเดียวเป็นจำนวนมากล่วงหน้า มีแค่จอเล็ก ๆ นั้นที่ update ต่อผู้เข้าชม และมัน update หลังจาก brochure อยู่ในมือคนแล้ว partialPrefetching คือตัวตัดสินว่า brochure นั้นถูกแจกจ่ายล่วงหน้าเร็วแค่ไหนก่อนที่ใครจะเดินผ่านมาขอด้วยซ้ำ — brochure ที่ออกแบบไว้ตัวเดียวต่อปลายทาง แทนที่จะพิมพ์ใหม่ทีละคนที่เดินผ่าน

สิ่งที่ได้ประโยชน์ต้นทุน
PPR (cacheComponents: true)shell เร็วเท่า static บวก hole ต่อ request ใน route เดียวทุกการอ่านแบบ dynamic ต้องอยู่ใน <Suspense> หรือ 'use cache' — ไม่มี mode dynamic ทั้ง route แบบ implicit อีกแล้ว
connection()”รอ request จริง” แบบตรงไปตรงมา ไม่มีเงื่อนไขกันค่าออกจาก static shell แบบเด็ดขาด — cache หรือ prefetch ไม่ได้เลย
io()กันออกแบบเดียวกัน แต่เป็น no-op (และ cache ได้) ใน 'use cache'เฉพาะ Cache Components เท่านั้น ไม่ใช่ตัวแทนที่ใช้แทนกันได้นอกบริบทนั้น
partialPrefetching: trueprefetch App Shell ที่แชร์กันตัวเดียวต่อ route แทนที่จะเป็นหนึ่งต่อ linkต้องมี cacheComponents เปิดอยู่แล้ว เป็น feature ของ 16.3+ ไม่มีใน 16.x รุ่นก่อนหน้า
  • Suspense fallback เป็นแค่ spinner โหลดที่หายไปเร็ว ๆ มันถูกฝังลงใน static shell และเสิร์ฟให้ทุกผู้เข้าชม รวมถึง client ที่รอ stream ไม่ได้ (crawler บางตัว) — ปฏิบัติกับ content ของมันจริงจังเท่ากับส่วนที่เหลือของหน้า static
  • connection() กับ io() ใช้แทนกันได้ io() คือตัวเลือกที่ native กับ Cache Components — มันเป็น no-op (เลย cache ได้) ใน scope 'use cache' ส่วน connection() กันโค้ดออกจาก shell แบบไม่มีเงื่อนไขเสมอ
  • partialPrefetching เป็นแค่ชื่ออีกชื่อของ cacheComponents มันคือ config key แยกที่เพิ่งมาใน 16.3.0 ที่ต้องมี cacheComponents: true ตั้งไว้แล้วก่อน — เปิดแค่ cacheComponents เฉย ๆ ไม่ได้เปิด App Shell prefetching แบบใหม่นี้
  • PPR แปลว่าทั้งหน้าเป็น dynamic ถ้ามีส่วนไหนเป็น dynamic ตรงข้ามเลยคือประเด็นทั้งหมด — มีแค่โค้ดที่อยู่ใน <Suspense> boundary (หรือนอก scope 'use cache') เท่านั้นที่ถูกกันออกจาก static shell ส่วนที่เหลือยัง prerender ตามปกติ

เมื่อ snippet ที่ generate มาแตะ PPR หรือ Cache Components ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน —

  • component อ่าน cookies(), headers(), searchParams หรือ fetch ที่ไม่ cache โดยไม่มี <Suspense> boundary หรือ scope 'use cache' ห่ออยู่หรือเปล่า? ภายใต้ cacheComponents นั่นคือ build ล้ม ไม่ใช่ surprise ตอน runtime
  • โค้ดใช้ Math.random(), Date.now() หรือ DB call แบบ synchronous ตรง ๆ โดยสมมติว่ามันจะทำงานได้เองตอน request time โดยไม่มี connection() หรือ io() มากันออกจาก static shell หรือเปล่า?
  • partialPrefetching ถูกตั้งโดยไม่มี cacheComponents: true อยู่ก่อนแล้วหรือเปล่า? framework จะ throw ตอน validate config — สอง flag นี้แยกกันแต่พึ่งพากัน
  • fallback UI ของ Suspense boundary มีอะไรที่ไม่ควรโชว์ให้ผู้เข้าชมทุกคนเห็นเป็น default หรือเปล่า (ราคาที่ดูเหมือนของจริง, username ปลอม) — ลืมไปว่า fallback อยู่ใน static shell ไม่ใช่แค่ loading state ชั่วคราว?
  • โค้ดสมมติว่า export const experimental_ppr = true หรือ experimental.ppr ใน next.config ยังทำอะไรได้ใน Next.js 16 หรือเปล่า? ทั้งคู่ถูกเอาออกไปแล้ว — PPR เปิดอยู่แค่ผ่าน cacheComponents: true เท่านั้น

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

// app/dashboard/page.tsx — next.config.ts has cacheComponents: true
import { cookies } from 'next/headers'
export default async function Dashboard() {
const store = await cookies()
const theme = store.get('theme')?.value ?? 'light'
return (
<main>
<h1>Dashboard</h1>
<p>Active theme: {theme}</p>
</main>
)
}
เฉลย

ภายใต้ cacheComponents: true cookies() ไม่ทำให้ทั้ง route flip เป็น dynamic แบบโมเดลเดิมอีกแล้ว — ตอนนี้มันต้องการ <Suspense> boundary ชัดเจนรอบโค้ดที่อ่านมัน page นี้ทำให้ next build ล้มด้วย Next.js encountered uncached or runtime data during prerendering. fetch(...), cookies(), headers(), params, searchParams, or connection() accessed outside of <Suspense> prevents the route from being prerendered... เป๊ะ ๆ ทางแก้คือย้ายการอ่าน cookies() ไปไว้ใน child component แล้วห่อ child นั้นด้วย <Suspense fallback={...}> เพื่อให้ heading prerender เป็น static shell และมีแค่ paragraph ที่พึ่ง theme เท่านั้นที่กลายเป็น dynamic hole

Partial Prerendering รวมอะไรไว้ใน route เดียว?
dynamic hole แต่ละอันใน PPR route ถูกส่งมายังไง?
เปิดใช้ PPR ใน Next.js 16 ยังไง?
PPR ต่างจาก route แบบ dynamic ล้วนยังไง?
อะไรที่ถูกส่งอยู่ใน static shell ของ PPR route จริง ๆ?
ทำไม connection() ถึงจำเป็นตรงนี้ ทั้งที่ query ไม่มี cookies(), headers() หรือ fetch() เลย?
async function getVisitorCount() {
  await connection()
  return db.prepare('SELECT value FROM counters WHERE name = ?').get('visitors')
}
config flag partialPrefetching ต้องการอะไร และมันเปลี่ยนอะไรไปจากเดิม?