Skip to content

บทที่ 11 — Next.js (App Router, Server Components, Server Actions) ​

ทำเว็บแบบ full-stack ด้วย React — โหลดเร็ว, SEO ดี, มี server component ในตัว

← บทที่ 10 | สารบัญ | บทที่ 12 →

🔴 ระดับ: สูง — อ่านทีหลังได้ Next.js เป็น framework เต็มตัว เหมาะเมื่อคุ้น React SPA แล้ว ถ้าเป็นมือใหม่ ทำแอป SPA (บท 0–8) ให้คล่องก่อน

ก่อนเข้าเนื้อหา ขอปูพื้นคำที่จะเจอบ่อยในบทนี้สักนิด โดยเฉพาะ hydration ซึ่งหลายคนอ่านผ่านแล้วงงซ้ำๆ

ให้คิดแบบนี้: server เหมือนเครื่องพิมพ์ที่พิมพ์ "ภาพถ่าย" ของหน้าเว็บลงกระดาษ (= HTML) แล้วส่งให้ browser ดูได้ทันที — แต่กดปุ่มอะไรไม่ได้เลย เพราะมันแค่รูปถ่าย

Hydration คือตอนที่ React วิ่งมาตามหลัง อ่านภาพถ่ายนั้น แล้ว "ติดสวิตช์ไฟฟ้า" ให้ทุกปุ่มกดได้จริง — เปลี่ยนจากรูปนิ่งกลายเป็นแอปที่ใช้งานได้

คำที่เหลือในตารางด้านล่างก็อิงจากแนวคิดนี้

📖 คำศัพท์รวมของบท (เจอบ่อย จำไว้ก่อน):

  • SSR (Server-Side Rendering) = render หน้าเว็บที่ฝั่ง server แล้วส่ง HTML สำเร็จมาให้ browser
  • SSG (Static Site Generation) = สร้างหน้า HTML ไว้ล่วงหน้าตอน build
  • ISR (Incremental Static Regeneration) = SSG ที่ทยอยสร้างหน้าใหม่เป็นช่วง ๆ
  • CSR (Client-Side Rendering) = render ที่ฝั่ง browser แบบ SPA ปกติ
  • PPR (Partial Prerendering) = ผสม static (ส่วนที่ไม่เปลี่ยน) + dynamic (ส่วนที่เปลี่ยนตามผู้ใช้) ในหน้าเดียว — ได้ทั้งความเร็วและข้อมูลสด
  • RSC (React Server Components) = component ที่รันบน server ไม่ส่ง JS ไป client
  • SEO = ทำให้ Google ค้นเจอเว็บง่ายขึ้น
  • App Router = ระบบ routing ใหม่ของ Next.js 13+ (folder app/) — ใช้ RSC default
  • Pages Router = ระบบเก่าของ Next.js 12 และต่ำกว่า (folder pages/) — บทนี้ไม่ครอบ
  • route segment = แต่ละชั้นของ folder ใน app/ ที่ map เป็น route
  • hydration = "เติมชีวิต" ให้ HTML ที่ server ส่งมา → กลายเป็น React app ที่กดได้
  • streaming = ส่ง HTML ทีละชิ้นแทนรอครบแล้วส่งทีเดียว
  • directive = string พิเศษ ('use client', 'use server', 'use cache') บอก bundler (เครื่องมือรวมไฟล์โค้ด) ให้ปฏิบัติพิเศษ
  • edge runtime = JS engine เบาๆ ที่รันใกล้ user (CDN edge) — เร็วกว่า Node แต่ feature น้อยกว่า
  • TTFB / LCP / INP / CLS = ตัววัด Web Vitals (รายละเอียดบท 10)
  • RPC (Remote Procedure Call) = เรียก function ที่อยู่อีกเครื่อง (server) ได้เหมือนเรียก function ที่อยู่ในโค้ดเดียวกัน — ทำให้ไม่ต้องสร้าง API endpoint เอง Server Action ทำงานแบบนี้

🗺️ ภาพรวม request lifecycle — สำคัญมาก ให้เห็นภาพก่อนเข้าเนื้อ:

  • SPA: ตอนแรก user เห็นจอขาว → spinner → ค่อยเห็น content
  • SSR: ตอนแรก user เห็น content เลย → ค่อย interactive ทีหลัง

บทที่ 0-10 = React SPA — JS render ทั้งหมดใน browser

ปี 2026 Next.js 15+ = หนึ่งใน React framework ที่ได้รับความนิยมสูงที่สุด และเป็น standard สำหรับ production app ที่ต้องการ SSR/SSG/RSC เพราะมี:

  • SSR / SSG / ISR / PPR — render บน server → SEO ดี + first load เร็ว
  • Server Components (RSC) — async component ที่ run บน server (ไม่ส่ง JS)
  • Server Actions — function call จาก client ไป server โดยไม่ต้องทำ API
  • Partial Prerendering (PPR) — รวม static (ส่วนนิ่ง) + dynamic (ส่วนเปลี่ยนได้) ในหน้าเดียว (Next 15)
  • Built-in: routing, image optimization, fonts, middleware
    • parallel routes (route ขนานกัน — render หลาย slot พร้อมกัน) และ intercepting routes (route ที่ดักจับ — เปิดเป็น modal แทนเปลี่ยนหน้า) — จะอธิบายละเอียดในส่วนท้ายของบทนี้ ตอนนี้แค่รับรู้ว่ามีก็พอ

หลังจบบท คุณจะ:

  • สร้าง Next.js 15 project ด้วย App Router
  • เข้าใจ Server Component vs Client Component
  • ใช้ Server Action + useOptimistic + useActionState ทำ form แบบ React 19
  • เลือกระหว่าง SSR / SSG / ISR / CSR / PPR
  • เข้าใจ async params, cookies, headers (breaking change ใน Next 15)
  • ใช้ Parallel routes + Intercepting routes สำหรับ modal/sidebar
  • ใช้ Image, Font, Metadata ของ Next.js

1. Next.js คืออะไร ​

Next.js = framework ที่ครอบ React + บอกว่า "ทำเป็นแบบนี้":

  • ไฟล์ใน app/ folder = route อัตโนมัติ (file-based routing)
  • ทุก component default = Server Component
  • ใส่ 'use client' เมื่อต้อง interactive
  • ใช้ <Link>, <Image>, <Form> ของ Next ที่ optimized

2. Vite + React vs Next.js — ใช้ตัวไหน? ​

Vite + ReactNext.js
TypeSPAfull-stack (ครอบคลุมทั้ง frontend + backend ในที่เดียว)
SEOลำบาก (CSR)ดี (SSR/SSG)
Initial loadช้ากว่า (download JS ก่อน)เร็วกว่า (HTML render เสร็จ)
Backend ต่อexternal APIbuilt-in + Server Actions
Deploystatic host (ที่เก็บไฟล์ HTML/CSS/JS ธรรมดา — Vercel/Netlify)Vercel / Node server / Docker / Cloudflare / Netlify (มี adapter)
Learning curveต่ำสูงขึ้น
ใช้เมื่อdashboard, admin tool, app ภายในlanding, blog, e-commerce, SaaS public

💡 Next.js deploy ที่ Vercel ได้ดีที่สุด (first-party) แต่ก็ deploy ที่อื่นได้เต็มที่ เช่น Node server, Docker, Cloudflare Workers, Netlify ผ่าน adapter

กฎ:

  • App ที่ user ต้อง login ก่อนใช้ (เช่น dashboard, admin) = ไม่ต้องการ SEO → ใช้ Vite + React
  • Site ที่ต้อง SEO หรือ public-facing = ใช้ Next.js

3. สร้าง Project ​

เริ่มโปรเจกต์ Next.js ด้วย create-next-app ที่จะถามตัวเลือก setup — แนะนำเลือก TypeScript, Tailwind, และ App Router (สำคัญ เพราะเป็นสถาปัตยกรรมใหม่ที่บทนี้สอน) ส่วน Turbopack เลือกได้เพราะ build เร็วกว่า Webpack:

💡 npm (Node Package Manager) = เครื่องมือจัดการ library/package ใน JavaScript ที่มาพร้อมกับ Node.js — อธิบายละเอียดในบทที่ 0 💡 npx = รัน package โดยไม่ต้องลงก่อน (มากับ npm) — เหมาะกับ scaffolding tool (เครื่องมือสร้าง structure โปรเจกต์เริ่มต้น) ที่ใช้ครั้งเดียว

bash
npx create-next-app@latest my-app

จะถาม:

  • TypeScript? → Yes
  • ESLint? → Yes
  • Tailwind? → Yes
  • src/ directory? → Yes
  • App Router? → Yes ⭐
  • Turbopack? → Yes (Turbopack/Webpack = bundler — เครื่องมือรวมไฟล์ JS เข้าด้วยกัน; Turbopack ใหม่กว่า/เร็วกว่า)
  • Import alias? → @/*
bash
cd my-app
npm run dev

เปิด http://localhost:3000


4. โครงสร้าง App Router ​

text
src/app/
├── layout.tsx              ← root layout (wrap ทุกหน้า)
├── page.tsx                ← /  (homepage)
├── globals.css
├── about/
│   └── page.tsx            ← /about
├── blog/
│   ├── page.tsx            ← /blog
│   └── [slug]/
│       └── page.tsx        ← /blog/[slug] (dynamic)
└── dashboard/
    ├── layout.tsx          ← layout เฉพาะ /dashboard/*
    ├── page.tsx            ← /dashboard
    └── settings/
        └── page.tsx        ← /dashboard/settings

Convention:

  • page.tsx = หน้า route นั้น
  • layout.tsx = wrap หน้า + child route
  • loading.tsx = แสดงระหว่างโหลด (Suspense)
  • error.tsx = แสดงเมื่อ error (Error Boundary)
  • not-found.tsx = 404
  • [param] = dynamic segment
  • (group) = group ไม่นับเป็น URL

5. Root Layout ​

layout.tsx ที่ root คือ shell ที่ห่อทุกหน้า — มี <html>/<body> และส่วนที่ใช้ร่วม (header/footer) ส่วน {children} คือที่ render หน้าแต่ละหน้า layout ไม่ re-render เมื่อเปลี่ยนหน้า (เก็บ state ของ nav ได้) ตรงนี้ยังกำหนด metadata เริ่มต้นด้วย:

tsx
// src/app/layout.tsx
import './globals.css';
import type { Metadata } from 'next';

export const metadata: Metadata = {
    title: 'My App',
    description: 'Built with Next.js 15',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en">
            <body>
                <header>...</header>
                <main>{children}</main>
                <footer>...</footer>
            </body>
        </html>
    );
}

6. Page ​

page.tsx คือเนื้อหาของ route หนึ่ง — Next.js map ตามโครงสร้างโฟลเดอร์ (file-based routing) ไฟล์ที่ default export component จะกลายเป็นหน้านั้นทันที ต่างจาก React Router ที่ต้องประกาศ route เอง:

tsx
// src/app/page.tsx
export default function Home() {
    return (
        <div>
            <h1>Hello, Next.js!</h1>
        </div>
    );
}

ไม่ต้อง import React — Next.js handle ให้


Part 1: Server Component (ดาวเด่น) ​

⚠️ ข้อควรรู้ก่อน (Heads-up — Next 15): params, searchParams, cookies(), headers(), draftMode() ทุกตัวเป็น Promise ต้อง await ก่อนใช้ — ดู §24.1 สำหรับ migration codemod. ตัวอย่างที่ตามมาจะใช้ async pattern ทั้งหมด (ถ้ายังไม่รู้ว่า params/cookies คืออะไร ข้ามไปก่อนได้ จะอธิบายในส่วนหลัง)

7. Server Component คืออะไร ​

ใน App Router — ทุก component default = Server Component

tsx
// src/app/users/page.tsx
async function UsersPage() {
    const users = await fetch('https://api.example.com/users').then(r => r.json());
    
    return (
        <ul>
            {users.map((u: User) => <li key={u.id}>{u.name}</li>)}
        </ul>
    );
}

export default UsersPage;

⭐ สังเกต:

  • async function ปกติ
  • await fetch() ตรง ๆ ใน component
  • ไม่มี useState, useEffect
  • JS ที่ส่งไป client = 0 bytes สำหรับ component นี้

8. ทำไม Server Component ดี ​

Client (SPA เดิม)Server Component
Data fetchuseEffect + stateawait ใน component
JS shippedทั้ง component0 (แค่ HTML)
SEObot อาจไม่เห็น databot เห็นทุกอย่าง
DB accessผ่าน API endpointdirect (มี privacy)
Initial loadslow (download → render → fetch → render)fast (HTML มาพร้อม data)

9. Direct DB call ​

ความสามารถที่ Server Component ปลดล็อก — เรียก DB ตรงจากใน component ได้เลย ไม่ต้องสร้าง API endpoint คั่น เพราะโค้ดนี้รันบน server เท่านั้น DB credential จึงไม่หลุดไป client (ต่างจาก SPA ที่ต้องผ่าน API layer เสมอ):

📖 Prisma = ORM (Object-Relational Mapper) ที่ช่วย query database ด้วย TypeScript แทนการเขียน SQL ตรงๆ @/lib/db คือไฟล์ที่ต้องสร้างเองเพื่อ export Prisma client — ตัวอย่างนี้แสดง concept เท่านั้น ต้องมี Prisma setup ก่อนจึงจะใช้ได้จริง (ดูบทที่สอน Prisma setup)

tsx
// src/app/users/page.tsx
import { prisma } from '@/lib/db';

export default async function UsersPage() {
    const users = await prisma.user.findMany();    // ⭐ DB query ใน component!
    
    return (
        <ul>
            {users.map(u => <li key={u.id}>{u.name}</li>)}
        </ul>
    );
}

⚠️ Server Component อยู่บน server เท่านั้น — DB credential ไม่หลุดไป client


10. Client Component — 'use client' ​

Server Component ทำ interactive ไม่ได้ (ไม่มี state/event) — เมื่อต้องการ interactivity ให้ใส่ directive 'use client' บรรทัดบนสุดของไฟล์เพื่อบอก Next ว่า component นี้รันบน browser ได้ ใช้ hook/event ได้ ส่วน Server Component ก็ render Client Component เป็นลูกได้ปกติ:

ต้องการ interactive (button, form, state, event)? → มาร์คเป็น Client Component:

tsx
// src/app/counter.tsx
'use client';                          // ⭐ บรรทัดบนสุด

import { useState } from 'react';

export function Counter() {
    const [count, setCount] = useState(0);
    return (
        <button onClick={() => setCount(c => c + 1)}>
            Count: {count}
        </button>
    );
}

ใช้ใน Server Component:

tsx
// app/page.tsx (Server Component)
import { Counter } from './counter';

export default function Page() {
    return (
        <div>
            <h1>Static title</h1>           {/* server-rendered */}
            <Counter />                      {/* client-rendered, hydrated */}
        </div>
    );
}

11. กฎ — Server vs Client ​

ทำได้ในServerClient
async/await✅❌
Direct DB / fs (filesystem) / env (secret — ค่าลับ)✅❌
useState / useEffect / hooks❌✅
onClick / onChange / event❌✅
useContext hook❌✅
Render Client Component✅✅
Render Server Component✅⚠️ pass เป็น children

💡 Server Component ยังสามารถ render <MyContext.Provider> ได้ แต่ถ้าต้องการอ่านค่า context ด้วย hook (useContext) ต้องทำใน Client Component เท่านั้น

กฎ: Server → Client = OK, Client → Server = ผ่าน children ​

ก่อนดูโค้ด ลองจินตนาการว่า Client Component คือห้องในบ้านที่ใช้ไฟฟ้า — ทุกอย่างในห้องนั้นต้องเข้าใจและรองรับระบบไฟฟ้าของ browser (state, event, hook) Server Component คือของที่ผลิตในโรงงาน (server) — มันไม่มีปลั๊กไฟ ถ้าคุณลากของจากโรงงานมาวางในห้องไฟฟ้าตรงๆ ปลั๊กไม่พอดี ระบบช็อตทันที แต่ถ้าคุณ "บรรจุกล่อง" (ส่งเป็น children) แล้วให้ห้องแค่แกะกล่องวางไว้ตรงนั้น — ทำได้ เพราะห้องไม่ต้องรู้ว่าของในกล่องทำงานอย่างไร

tsx
// ❌ Client component import Server component ตรง ๆ ไม่ได้
'use client';
import { ServerComp } from './server';   // ❌

// ✅ Pattern: Client wrapper ที่รับ children
// app/components/ClientWrapper.tsx
'use client';
import { useState, ReactNode } from 'react';

export function ClientWrapper({ children }: { children: ReactNode }) {
    const [open, setOpen] = useState(true);
    return (
        <div>
            <button onClick={() => setOpen(o => !o)}>Toggle</button>
            {open && children}    {/* ⭐ children = Server Component */}
        </div>
    );
}

// app/page.tsx (Server)
import { ClientWrapper } from './components/ClientWrapper';
import { ServerComp } from './ServerComp';

export default function Page() {
    return (
        <ClientWrapper>
            <ServerComp />    {/* ✅ render บน server, ส่ง HTML ลง client */}
        </ClientWrapper>
    );
}

💡 Mental model (วิธีคิดเพื่อทำความเข้าใจ): children prop = "slot" ที่ Next render ใน server ก่อน แล้วใส่ใน client component → client ไม่ต้อง execute (รัน) server code, แค่ render HTML ที่ได้ (ดูผลลัพธ์แล้วแสดง)


Part 2: Data Fetching Patterns ​

ก่อนดูโค้ดแต่ละ pattern คิดถึงร้านอาหารก่อน:

  • SSG = พิมพ์เมนูไว้ล่วงหน้าก่อนเปิดร้าน ถูกและเร็ว แต่ถ้าวันนี้ก๋วยเตี๋ยวหมด เมนูก็ยังแสดงอยู่จนกว่าจะพิมพ์ใหม่
  • ISR = พิมพ์เมนูใหม่ทุก 60 นาที ลูกค้าส่วนใหญ่เห็นของสด แต่อาจเก่าได้ไม่เกิน 60 นาที
  • SSR = พิมพ์เมนูใหม่ทุกครั้งที่ลูกค้าถาม สดที่สุด แต่ใช้แรงพิมพ์มาก
  • CSR = ไม่มีเมนูกระดาษเลย ให้ลูกค้าดูแอปในมือถือตัวเองแทน

ความแตกต่างในโค้ดคือ option เดียวใน fetch() ที่บอก Next ว่าจะ "พิมพ์เมนู" ตอนไหน

12. SSG — Static Site Generation ​

Build time → fetch + generate HTML → deploy เป็น static file

📖 build time = ตอนที่รัน npm run build ก่อน deploy ขึ้น server ต่างจาก runtime = ตอนที่ user เข้าใช้งาน website จริงๆ SSG สร้าง HTML ตอน build ครั้งเดียว ทุก user ที่เข้ามาได้ไฟล์เดียวกันเลย — เร็วมากแต่ข้อมูลจะสดได้แค่ตอน build

tsx
// src/app/blog/page.tsx
export default async function BlogList() {
    const posts = await fetch('https://cms.example.com/posts').then(r => r.json());
    return <PostList posts={posts} />;
}

ทุก request → return HTML ที่ build ไว้ — เร็วสุด, ใช้กับ content ที่ไม่เปลี่ยน (blog, marketing)


13. SSR — Server-Side Rendering ​

ต่างจาก SSG ที่ build ครั้งเดียว SSR จะ fetch + render ใหม่ทุก request — เหมาะกับข้อมูลที่ต้องสดเสมอ (dashboard, search) ใน Next.js สั่งด้วย cache: 'no-store' เพื่อบอกว่าห้าม cache fetch นี้:

ทุก request → fetch + render — สำหรับ data ที่ต้องสด

cache: 'no-store' = บอก Next ว่า "ห้ามเก็บผล fetch นี้ไว้เลย" — ทุก request ต้องวิ่งไปถาม API ใหม่ทุกครั้ง ไม่มีการนำผลเก่ามาใช้

tsx
export default async function Dashboard() {
    const stats = await fetch('https://api.example.com/stats', { 
        cache: 'no-store'                  // ⭐ ห้าม cache
    }).then(r => r.json());
    return <Stats data={stats} />;
}

14. ISR — Incremental Static Regeneration ​

generate ครั้งแรก แล้ว revalidate ทุก X วินาที

next: { revalidate: 60 } = บอก Next ว่า "ใช้ผล fetch ที่เก็บไว้ได้ แต่ถ้าเก่าเกิน 60 วินาทีแล้วมีคนมาขอ ให้ไปดึงใหม่ในพื้นหลัง" — user ส่วนใหญ่ได้ข้อมูลจาก cache (เร็ว) และข้อมูลเก่าได้ไม่เกิน 60 วินาที

tsx
export default async function Products() {
    const products = await fetch('https://api.example.com/products', {
        next: { revalidate: 60 }           // ⭐ regenerate ทุก 60 วินาที
    }).then(r => r.json());
    return <ProductList products={products} />;
}

→ ส่วนใหญ่ user ได้ cache (เร็ว) + data freshness ระดับนาที


15. CSR — Client-Side Rendering ​

ใช้ Client Component + useEffect หรือ TanStack Query (เหมือน Vite + React)

tsx
'use client';
import { useQuery } from '@tanstack/react-query';

export function Dashboard() {
    const { data } = useQuery({ queryKey: ['stats'], queryFn: fetchStats });
    // ...
}

ใช้สำหรับ data ที่ผูกกับ user (dashboard หลัง login)


16. ตารางสรุป — เลือกแบบไหน ​

Patternเมื่อตัวอย่าง
SSGdata ไม่เปลี่ยน + same สำหรับทุก userblog post, landing
ISRdata เปลี่ยนช้า ๆproduct catalog
SSRdata ต้องสดทุก requestdashboard public, search results
CSRdata ผูก user + interactiveprivate dashboard, settings

Part 3: Server Actions ​

17. Server Action คืออะไร ​

Server Action = function ที่ประกาศบน server แต่ เรียกจาก client — เหมือนกดปุ่มใน browser แล้ว code ไปทำงานบน server ให้อัตโนมัติ (เหมือน RPC) ผู้ใช้ไม่เห็นว่าเกิดอะไรขึ้น แต่ข้อมูลเปลี่ยนได้โดยไม่ต้องสร้าง API endpoint แยก

คิดว่า Server Action เหมือน โทรศัพท์สายตรงในออฟฟิศ — คุณกดปุ่ม (browser) แล้วสายตรงไปที่แผนกบัญชี (server) โดยอัตโนมัติ คุณไม่ต้องรู้หมายเลขโทรศัพท์ (URL ของ API endpoint) ไม่ต้องรอสายเชื่อมต่อ (ไม่ต้องสร้าง route ใน Express/Fastify) Next.js เป็นคนต่อสายให้ทั้งหมด เวลา browser submit form → Next ส่ง HTTP POST request ไปยัง endpoint ที่ generate อัตโนมัติ → server รัน function นั้น → ส่งผลกลับ ทุกอย่างเกิดขึ้นใต้ประทุน คุณเห็นแค่ "เรียก function เหมือนปกติ"

ไม่ต้องสร้าง API endpoint — เรียกตรงเลย

tsx
// app/users/actions.ts
'use server';                              // ⭐ บรรทัดบน

import { prisma } from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createUser(formData: FormData) {
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;
    
    const user = await prisma.user.create({ data: { name, email } });
    
    revalidatePath('/users');               // บอก Next.js ให้ล้าง cache ของหน้า /users → ครั้งต่อไปที่ user เข้า จะดึงข้อมูลใหม่จาก DB
    return user;
}

export async function deleteUser(id: number) {
    await prisma.user.delete({ where: { id } });
    revalidatePath('/users');
}

18. ใช้ใน Form (Progressive Enhancement) ​

tsx
// app/users/new/page.tsx (Server Component)
import { createUser } from '../actions';

export default function NewUserPage() {
    return (
        <form action={createUser}>           {/* ⭐ pass action ตรง ๆ */}
            <input name="name" required />
            <input name="email" type="email" required />
            <button type="submit">Create</button>
        </form>
    );
}

ความพิเศษของ pattern นี้ (Magic):

  • ทำงานแม้ JS ปิด (HTML form ปกติ)
  • ถ้า JS เปิด — Next.js intercept + AJAX
  • ไม่มี API endpoint ต้องสร้าง

19. ใช้ใน Client Component ​

Server Action เรียกจาก Client Component ได้ด้วย — import มาใช้ใน event handler แล้วห่อด้วย useTransition เพื่อจัดการ pending state (แสดง loading ระหว่างที่ action ทำงานบน server) ได้ความ interactive เต็มที่โดยยังไม่ต้องสร้าง API:

tsx
'use client';
import { createUser } from './actions';
import { useTransition } from 'react';

export function CreateForm() {
    const [isPending, startTransition] = useTransition();
    
    async function handleSubmit(formData: FormData) {
        startTransition(async () => {
            await createUser(formData);
        });
    }
    
    return (
        <form action={handleSubmit}>
            <input name="name" />
            <button disabled={isPending}>
                {isPending ? 'Creating...' : 'Create'}
            </button>
        </form>
    );
}

20. Server Action + React Hook Form + Zod ​

ในงานจริง form มักต้องการ validation ฝั่ง client (UX ดี) + ฝั่ง server (ปลอดภัย) — pattern ที่ดีคือใช้ Zod schema ร่วมกันทั้งสองฝั่ง: react-hook-form validate ที่ client ก่อน แล้ว Server Action validate ซ้ำด้วย schema เดียวกันก่อนเขียน DB:

📖 Zod = library สำหรับ validate ข้อมูล โดยกำหนด schema ว่าข้อมูลต้องมีรูปแบบอะไร ถ้าไม่ตรงจะแจ้ง error — ใช้ได้ทั้งฝั่ง client และ server

💡 สังเกตว่าตัวอย่างก่อนหน้า (§17-19) ส่ง Server Action ด้วย FormData แต่ตัวอย่างนี้ส่งเป็น plain object (z.infer<typeof schema>) ตรงๆ — ทำได้ทั้งสองแบบ เพราะ Server Action รับ argument อะไรก็ได้ที่ serialize (แปลงเป็นข้อมูลส่งผ่านเครือข่ายได้) เช่น string, number, object ธรรมดา ไม่ได้จำกัดแค่ FormData เลือกแบบไหนก็ได้ตามที่ฝั่ง client เตรียมข้อมูลมา

tsx
// schema (ใช้ร่วมกันทั้ง client และ server)
import { z } from 'zod';

const schema = z.object({
    name: z.string().min(1),
    email: z.string().email(),             // ทำงานได้ทั้ง Zod v3 และ v4
});

// action
'use server';
import { z } from 'zod';
import { prisma } from '@/lib/db';

export async function createUser(data: z.infer<typeof schema>) {
    const result = schema.safeParse(data);
    if (!result.success) {
        return { error: result.error.flatten().fieldErrors };  // Zod v3/v4 compat
    }
    const user = await prisma.user.create({ data: result.data });
    return { user };
}

// form (client)
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createUser } from './actions';

export function CreateUserForm() {
    const form = useForm({ resolver: zodResolver(schema) });

    const onSubmit = form.handleSubmit(async (data) => {
        const result = await createUser(data);
        if (result.error) {
            // handle validation errors
        }
    });

    return (
        <form onSubmit={onSubmit}>
            <input {...form.register('name')} />
            <input {...form.register('email')} />
            <button type="submit">Create</button>
        </form>
    );
}

→ end-to-end type-safe + validation 2 ฝั่ง


20.1 React 19 Actions Pattern — useActionState + useFormStatus + useOptimistic ​

React 19 มี 3 hooks ใหม่ที่จับคู่กับ Server Action — ลด boilerplate ใน form มาก

useActionState — return state จาก action ​

tsx
'use client';
import { useActionState } from 'react';
import { saveProfile } from './actions';

type State = { error?: string; success?: boolean };

async function action(prevState: State, formData: FormData): Promise<State> {
    try {
        await saveProfile(formData);
        return { success: true };
    } catch (e) {
        return { error: (e as Error).message };
    }
}

export function ProfileForm() {
    const [state, formAction, isPending] = useActionState<State, FormData>(action, {});

    return (
        <form action={formAction}>
            <input name="name" />
            <button disabled={isPending}>Save</button>
            {state.error && <p className="text-red-500">{state.error}</p>}
            {state.success && <p className="text-green-500">Saved!</p>}
        </form>
    );
}

→ ไม่ต้อง useState + try/catch + useTransition แยก — รวมใน hook เดียว

useFormStatus — รู้สถานะ form จาก child ใดก็ได้ ​

useFormStatus อ่าน state ของ <form> ที่ครอบ component นี้อยู่ได้เอง โดยไม่ต้องส่ง prop ลงมา:

tsx
'use client';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
    const { pending } = useFormStatus();    // ⭐ อ่าน state ของ <form> ที่ครอบ component นี้อยู่ได้เลย
    return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}

export function MyForm() {
    return (
        <form action={saveProfile}>
            <input name="name" />
            <SubmitButton />    {/* ไม่ต้องส่ง pending prop ลงมา */}
        </form>
    );
}

useOptimistic — UI update ทันทีก่อน server response ​

tsx
'use client';
import { useOptimistic } from 'react';
import { addTodo } from './actions';

export function TodoList({ todos }: { todos: Todo[] }) {
    const [optimisticTodos, addOptimistic] = useOptimistic(
        todos,
        (current, newTodo: string) => [
            ...current,
            { id: Math.random(), title: newTodo, pending: true },
        ]
    );

    async function action(formData: FormData) {
        const title = formData.get('title') as string;
        addOptimistic(title);              // ⭐ UI update ทันที
        await addTodo(title);              // ส่งไป server
    }

    return (
        <>
            <form action={action}>
                <input name="title" />
                <button>Add</button>
            </form>
            <ul>
                {optimisticTodos.map(t => (
                    <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>
                        {t.title}
                    </li>
                ))}
            </ul>
        </>
    );
}

→ ผลลัพธ์: user เห็น todo ใหม่ทันที (กลายเป็นจางลง) — เมื่อ server ตอบ → revalidate → state จริงเข้ามา


20.2 ⚠️ Server Action Security ​

Server Action ถูก expose (เปิดเผยออกมา) เป็น POST endpoint (จุด API ที่รับ POST request) โดยอัตโนมัติ

ใครก็ส่ง request เข้ามาเรียกได้ ถ้ารู้ action ID (รหัสประจำตัว action ที่ Next.js สร้างอัตโนมัติ) ดังนั้นต้องเช็คสิทธิ์ใน action ทุกตัว

ต้องทำเสมอ:

  1. Auth check ใน action ทุกตัว — อย่า trust cookies() blindly
  2. Validate input ด้วย Zod เสมอ
  3. Authorize — user มีสิทธิ์แก้ data นี้จริงไหม
  4. Rate limit สำหรับ public action
tsx
'use server';

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { z } from 'zod';

const schema = z.object({ postId: z.string().uuid(), content: z.string().max(1000) });  // z.string().uuid() ใช้ได้ทั้ง Zod v3 และ v4

// discriminated union (ยูเนียนแบบแยกแยะได้) — pattern TypeScript ที่รวมหลาย type เข้าด้วยกัน แล้วมี field ร่วม (ที่นี่คือ ok) ให้ TypeScript แยกแยะว่ากำลังเจอ type ไหน
// ทำให้ caller เช็คได้ว่า action สำเร็จหรือล้มเหลวโดยดูจาก ok flag
// ถ้า ok=true → ใช้ data ได้ / ถ้า ok=false → ใช้ error ได้
type ActionResult<T> = { ok: true; data: T } | { ok: false; error: 'unauthorized' | 'forbidden' | 'invalid' };

export async function updatePost(input: unknown): Promise<ActionResult<{ id: string }>> {
    // 1. auth (ตรวจว่า login)
    const session = await auth();
    if (!session?.user) return { ok: false, error: 'unauthorized' };

    // 2. validate
    const parsed = schema.safeParse(input);
    if (!parsed.success) return { ok: false, error: 'invalid' };

    // 3. authorize (ตรวจสิทธิ์การเข้าถึง — ต่างจาก auth ข้อ 1 ที่แค่เช็คว่า login หรือเปล่า ข้อนี้เช็คว่า user มีสิทธิ์แก้ data นี้จริงไหม ดู ownership = ว่า user เป็นเจ้าของ data นี้)
    const post = await db.post.findUnique({ where: { id: parsed.data.postId } });
    if (post?.authorId !== session.user.id) return { ok: false, error: 'forbidden' };

    // 4. action จริง
    const updated = await db.post.update({
        where: { id: parsed.data.postId },
        data: { content: parsed.data.content },
    });
    return { ok: true, data: { id: updated.id } };
}

⚠️ อย่าใช้ throw new Error('Unauthorized') ใน Server Action — แม้ Next.js production จะ redact error message อัตโนมัติ แต่การใช้ typed return { ok: false, error: 'unauthorized' } ดีกว่าเพราะ: type-safe, ผู้เรียก handle ได้ทุก case, ป้องกัน unhandled rejection และไม่ต้องพึ่ง framework redact behavior


20.3 after() — code ที่ run หลัง response ส่งแล้ว ​

Next 15 เพิ่ม after() — เรียก function หลัง response ถึง user แล้ว (ไม่บล็อก)

tsx
import { after } from 'next/server';

export default async function Page() {
    const data = await fetchData();

    after(async () => {
        // ทำงานหลัง response ส่งแล้ว — user ไม่ต้องรอ
        await logAnalytics({ page: '/dashboard', userId: 'xxx' });
        await sendWebhook();
    });

    return <Dashboard data={data} />;
}

ใช้สำหรับ: analytics, logging, cache warming (เตรียม cache ล่วงหน้า — เรียก endpoint ก่อนเพื่อให้ข้อมูลพร้อม ตอน user เข้ามาจะได้ไม่ต้องรอ), webhook — งานที่ไม่ critical ต่อ user


Part 3.5: Next.js 15 Caching — 4 Layers ที่ต้องเข้าใจ ​

🔴 โซนขั้นสูง — ข้ามได้ ส่วนนี้ลงลึกเรื่องระบบ cache 4 ชั้นของ Next.js ซึ่งศัพท์เยอะและยาก ถ้าเพิ่งเริ่ม Next.js ข้ามไป Part 4 ก่อนได้เลย แล้วค่อยกลับมาอ่านตอนเจอปัญหา "ทำไมข้อมูลไม่อัปเดต" ในงานจริง

20.5 ทำไมต้องเข้าใจ Caching ของ Next 15 ​

หลายคนเขียน Next.js แล้ว confuse:

  • "ทำไม data เก่า? ผมแก้ใน DB แล้วนะ"
  • "cache: 'no-store' ต่าง revalidate: 0 ยังไง?"
  • "revalidatePath กับ revalidateTag ใช้ตอนไหน?"

คำตอบ — Next 15 มี 4 layers ของ cache ที่ทำงานต่างกัน:

ก่อนดู diagram ให้รู้ว่าแต่ละ layer อยู่ที่ไหนในโลกจริง คิดว่าเป็น 4 กล่องที่ซ้อนกัน กล่องในสุด (Layer 1) เล็กที่สุด อายุสั้นที่สุด กล่องนอกสุด (Layer 4) อยู่ไกลที่สุดจาก DB แต่เร็วที่สุดสำหรับ user:

  • Layer 1 อยู่ใน RAM ของ server ระหว่าง render ครั้งเดียว — หายทันทีตอน request จบ
  • Layer 2 อยู่ใน disk หรือ memory ของ Next server ข้ามหลาย request — persist อยู่นานจนกว่าจะ revalidate
  • Layer 3 อยู่ใน disk ของ Next เป็น HTML ที่ render แล้วทั้งหน้า — เหมือนไฟล์ HTML สำเร็จรูปที่เก็บไว้
  • Layer 4 อยู่ใน browser ของ user — ใกล้ user ที่สุด แต่ไกล database ที่สุด

แต่ละ layer มี trigger ลบ + scope ต่างกัน — ต้องรู้เพื่อแก้ "data เก่า" ได้:

  • [1] Request Memoization = cache ระหว่าง render ครั้งเดียว (เรียก function เดิมซ้ำใน request เดียว → DB query แค่ครั้งเดียว)
  • [2] Data Cache = cache ผล fetch ข้าม request (persist ข้าม user request)
  • [3] Full Route Cache = cache HTML ทั้งหน้าที่ render แล้ว
  • [4] Router Cache = cache ใน browser ของ user (navigate กลับมา → ไม่ fetch ใหม่)

20.6 Layer 1: Request Memoization (per-request) ​

Scope: ระหว่าง render 1 request เท่านั้น Auto — ไม่ต้อง opt-in

tsx
// app/users/page.tsx
import { cache } from 'react';

const getUser = cache(async (id: string) => {
    console.log('fetching user', id);  // ⭐ log แค่ครั้งเดียวต่อ request — หมายความว่า query DB แค่ครั้งเดียว แม้จะเรียก 2 ครั้ง
    return db.user.findUnique({ where: { id } });
});

async function Header() {
    const user = await getUser('1');  // fetch
    return <p>Hi {user.name}</p>;
}

async function Sidebar() {
    const user = await getUser('1');  // ⭐ ไม่ fetch ใหม่ — cache จาก Header
    return <p>{user.email}</p>;
}

// page.tsx
export default async function Page() {
    return (
        <>
            <Header />
            <Sidebar />
            {/* getUser('1') เรียก 2 ครั้ง → DB query แค่ 1 ครั้ง */}
        </>
    );
}

fetch() ของ Next ก็ memoize อัตโนมัติ — เรียก URL เดียวกัน 2 ครั้งใน request เดียว = 1 network call

💡 cache() ของ React เทียบ argument แบบนี้ได้ดีกับค่าง่ายๆ เช่น string/number (id) แต่ถ้าเรียกด้วย object หรือ array ที่เป็นคนละ reference กัน (เช่น { id: '1' } สร้างใหม่ทุกครั้ง) จะไม่ถือว่าเป็นค่าเดียวกัน — memoize ไม่ทำงาน ต้องระวังเวลาเรียกฟังก์ชันที่ห่อด้วย cache() ด้วย argument ที่เป็น object

เมื่อหมดอายุ: เมื่อ request จบ — ไม่ persist ข้าม request

20.7 Layer 2: Data Cache (cross-request) ​

ชั้นที่สองของ caching ใน Next คือ Data Cache ที่ persist ข้าม request (และข้าม deploy) — ต่างจาก request memoization ที่หายเมื่อ request จบ จุดที่ต้องจำคือ Next 15 เปลี่ยนเป็น "ไม่ cache by default" (ต่างจาก 14) ต้อง opt-in ด้วย cache/revalidate เอง:

Scope: persistent ข้าม request, ข้าม deploy Default: Next 15 — fetch ไม่ cache by default (เปลี่ยนจาก Next 14 ที่ cache by default)

tsx
// ❌ ไม่ cache (default)
await fetch('https://api.example.com/products');

// ✅ Cache ตลอด (SSG-like)
await fetch('https://api.example.com/products', {
    cache: 'force-cache',
});

// ✅ Cache + revalidate ทุก 60 วินาที (ISR-like)
await fetch('https://api.example.com/products', {
    next: { revalidate: 60 },
});

// ✅ Cache + tag → invalidate ผ่าน tag ได้
await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
});

Invalidate Data Cache ​

tsx
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';

export async function updateProduct(id: string, data: ProductInput) {
    await db.product.update({ where: { id }, data });

    // ตัวเลือก 1: invalidate by tag
    revalidateTag('products');           // ทุก fetch ที่มี tag 'products'

    // ตัวเลือก 2: invalidate by path
    revalidatePath('/products');         // ทุก fetch ที่ใช้ใน /products
    revalidatePath('/products/[id]', 'page');  // dynamic path
}

20.8 Layer 3: Full Route Cache (rendered HTML) ​

Scope: HTML + RSC payload ของ route ทั้งหน้า ตั้งตอน: build time (สำหรับ static route)

tsx
// Static (default) — cache HTML ตลอด
export default async function ProductsPage() {
    const products = await fetch('...').then(r => r.json());
    return <ProductList products={products} />;
}

// Dynamic — ไม่ cache (ทุก request render ใหม่)
export const dynamic = 'force-dynamic';
// หรือใช้ cookies()/headers()/searchParams ในหน้า — Next auto-detect

ตาราง: เมื่อไหร่ route กลายเป็น dynamic อัตโนมัติ

ใช้สิ่งนี้Route mode
cookies(), headers()dynamic
searchParams ใน pagedynamic
fetch({ cache: 'no-store' })dynamic
unstable_noStore()dynamic
ไม่มีอะไรข้างต้นstatic (default)

Invalidate Full Route Cache ​

tsx
revalidatePath('/products');         // invalidate ทั้ง full route cache + data cache ของ /products

หรือ deploy ใหม่ → cache ใหม่ทั้ง project

20.8.5 'use cache' directive — caching แบบใหม่ของ Next 15+ ​

⚠️ Status (mid-2026): experimental — 'use cache' ยังอยู่ใน Next 15 canary/experimental (เปิดด้วย experimental.useCache) และ helper unstable_cacheLife / unstable_cacheTag มี prefix unstable_ ที่บอกชัด API อาจเปลี่ยน. ใช้เรียนรู้/prototype ได้ แต่เช็ค Next.js release notes ก่อนใช้ production

Next 15 เพิ่ม directive 'use cache' — pattern ใหม่ที่ชัดและคุมง่ายกว่า fetch({ cache }) หรือ unstable_cache (ฟังก์ชัน cache แบบเก่าของ Next.js ที่ใช้ก่อน 'use cache' จะเสถียร):

tsx
// 'use cache' ที่ระดับ function — ทั้ง function cache
async function getProducts() {
    'use cache';
    return db.product.findMany();
}

// 'use cache' ที่ระดับ component
async function ProductList() {
    'use cache';
    const products = await db.product.findMany();
    return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

// 'use cache' ที่ระดับ file — ทุก export ของไฟล์
'use cache';
// ...

cacheLife — กำหนดอายุ cache ​

tsx
import { unstable_cacheLife as cacheLife } from 'next/cache';  // "as cacheLife" = ตั้งชื่อเล่นให้ตอน import (import aliasing) เพื่อให้อ่านง่ายในโค้ดที่เหลือ ชื่อจริงที่ export มาคือ unstable_cacheLife

async function getProducts() {
    'use cache';
    cacheLife('hours');  // หรือ 'seconds', 'minutes', 'days', 'weeks', 'max'
    return db.product.findMany();
}

// หรือ custom
cacheLife({ stale: 60, revalidate: 300, expire: 3600 });
// stale (ค่าเก่าที่ยังใช้ได้ชั่วคราว) = client cache  · revalidate = server background refresh · expire = หมดอายุจริง

cacheTag — invalidate ตาม tag ​

tsx
import { unstable_cacheTag as cacheTag } from 'next/cache';

async function getProduct(id: string) {
    'use cache';
    cacheTag(`product-${id}`, 'products');
    return db.product.findUnique({ where: { id } });
}

// ใน Server Action → invalidate ทั้งหมดที่มี tag นี้
'use server';
import { revalidateTag } from 'next/cache';

export async function updateProduct(id: string) {
    await db.product.update(...);
    revalidateTag(`product-${id}`);  // invalidate แค่สินค้านี้
    // หรือ revalidateTag('products') → invalidate ทุก product
}

💡 เทียบกับเดิม: fetch({ next: { tags } }) ทำได้แค่กับ fetch — 'use cache' ทำกับ DB query ตรงๆ ได้ ไม่ต้องห่อใน fetch

enable ใน Next 15:

ts
// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
    experimental: { useCache: true },
};

export default nextConfig;

20.8.6 Streaming + Suspense — โหลด UI เป็นชิ้นๆ ไม่รอทั้งหน้า ​

ปัญหาเดิม: หน้า dashboard มี 3 widget — ตัวที่ช้าสุดดึงข้อมูล 2 วินาที → user เห็นหน้าขาว 2 วินาที Streaming = ส่ง HTML เป็น chunk (ชิ้นๆ ทีละส่วน) → ส่วนพร้อม render ก่อน ส่วนช้า → spinner รอ

tsx
// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
    return (
        <>
            <h1>Dashboard</h1>
            {/* ส่วนนี้ render ทันที */}

            <Suspense fallback={<Skeleton />}>
                <SlowChart />  {/* ดึง data 2 วินาที — มี Skeleton คั่น */}
            </Suspense>

            <Suspense fallback={<Skeleton />}>
                <UserList />   {/* ดึง data 500ms */}
            </Suspense>
        </>
    );
}

async function SlowChart() {
    const data = await fetchSlowData();  // 2 วินาที
    return <Chart data={data} />;
}

เกิดอะไรขึ้นใน HTTP:

  1. Server ส่ง HTML แรก: <h1>Dashboard</h1> + Skeleton 2 ตัว → user เห็นทันที (~100ms)
  2. UserList เสร็จที่ 500ms → server stream HTML ของ UserList → swap Skeleton
  3. SlowChart เสร็จที่ 2s → stream HTML → swap Skeleton

Pattern พิเศษ — loading.tsx ที่ root ของ route segment เทียบเท่ากับครอบทั้งหน้าด้วย <Suspense>:

tsx
// app/dashboard/loading.tsx — Skeleton ของทั้งหน้า dashboard
export default function Loading() {
    return <DashboardSkeleton />;
}

⚡ TTFB เร็วขึ้นมาก + INP ดีขึ้น (เพราะ initial HTML เล็ก) — Streaming เป็นเหตุผลหลักที่ Next 15 + RSC เร็วกว่า traditional SSR เยอะ

📖 TTFB / INP / LCP / CLS = Web Vitals — ดูบท 10 §19 "Performance"


20.9 Layer 4: Router Cache (client-side) ​

Scope: ใน browser memory — เก็บ RSC payload ของ route ที่ user เคยเข้า ตั้งตอน: user navigate ใน app

tsx
// user คลิก Link → /products → fetch RSC payload → cache
// user คลิก Link → /about → fetch RSC payload → cache
// user คลิก Back → /products → ใช้ cache (ไม่ fetch ใหม่)

Default cache time (Next 15 ตอนเขียนบทนี้ — ค่า default นี้เปลี่ยนบ่อยข้าม point release ควรเช็ค Next.js caching docs ให้ตรงเวอร์ชันที่ใช้จริงก่อน rely):

  • Static page: 5 นาที (เปลี่ยนจาก 30 นาทีของ Next 14)
  • Dynamic page: 0 วินาที (ไม่ cache, เปลี่ยนจาก 30 วินาทีของ Next 14)

ปรับ staleTimes ​

ts
// next.config.ts — Next.js 15.1+ ย้าย staleTimes ออกจาก experimental แล้ว
const config = {
    staleTimes: {
        dynamic: 30,    // dynamic page cache 30 วินาที
        static: 180,    // static page cache 3 นาที
    },
};

Invalidate Router Cache ​

tsx
'use server';
import { revalidatePath } from 'next/cache';

export async function deletePost(id: string) {
    await db.post.delete({ where: { id } });
    revalidatePath('/');   // ⭐ invalidate ทั้ง 3 layers (Data + Full Route + Router)
}

หรือ client-side:

tsx
'use client';
import { useRouter } from 'next/navigation';

const router = useRouter();
router.refresh();    // invalidate router cache, refetch current route

20.10 Decision Tree — เมื่อข้อมูล "เก่า" ​

Next.js มี cache หลายชั้นซ้อนกัน เวลาเจอ "ข้อมูลเก่าไม่ยอม update" จึงงงว่าชั้นไหนค้าง — decision tree นี้ช่วยไล่หาทีละชั้น (router cache → full route cache → data cache → request memoization) เพื่อเลือก revalidate ให้ถูกตัว:

20.11 รวมตัวอย่าง — Blog ที่ caching ถูก ​

tsx
// app/posts/page.tsx (List)
export default async function PostsPage() {
    const posts = await fetch('https://api.example.com/posts', {
        next: { tags: ['posts'], revalidate: 300 },  // 5 นาที + tag
    }).then(r => r.json());

    return <PostList posts={posts} />;
}

// app/posts/[slug]/page.tsx (Detail)
type Props = { params: Promise<{ slug: string }> };

export default async function PostPage({ params }: Props) {
    const { slug } = await params;
    const post = await fetch(`https://api.example.com/posts/${slug}`, {
        next: { tags: [`post:${slug}`, 'posts'] },  // 2 tags
    }).then(r => r.json());

    return <PostDetail post={post} />;
}

// app/posts/actions.ts
'use server';
import { revalidateTag } from 'next/cache';

export async function updatePost(slug: string, data: PostInput) {
    await db.post.update({ where: { slug }, data });
    revalidateTag(`post:${slug}`);  // invalidate detail
    revalidateTag('posts');          // invalidate list
}

export async function deletePost(slug: string) {
    await db.post.delete({ where: { slug } });
    revalidateTag('posts');           // invalidate list
    redirect('/posts');
}

→ pattern นี้ scale ดี — แก้ post 1 ตัว → invalidate เฉพาะ list + detail นั้น, post อื่นๆ ไม่โดน refetch


Part 4: Built-in Components ​

Next มี built-in component ที่ optimize ให้หลายตัว เริ่มจาก <Link> — เปลี่ยนหน้าแบบ client-side (ไม่ reload) เหมือน React Router แต่เพิ่ม prefetch อัตโนมัติ (pre-load หน้าตอน hover) ทำให้ navigation รู้สึก instant:

tsx
import Link from 'next/link';

<Link href="/about">About</Link>
<Link href={`/blog/hello-world`}>Hello</Link>   {/* App Router: interpolate URL ตรงๆ ใน href ได้เลย ไม่ต้องใช้ as prop (deprecated ใน App Router) */}

Prefetch อัตโนมัติ — hover แล้ว pre-fetch page


22. <Image> — image ที่ optimized ​

<Image> ของ Next ทำงานหนักแทนเราเรื่องรูป — สร้าง responsive srcset, แปลงเป็น WebP/AVIF, lazy load, และ placeholder blur ให้อัตโนมัติ ช่วยคะแนน Core Web Vitals (LCP/CLS) มาก แค่ต้องระบุ width/height และ allow domain ของรูป remote:

tsx
import Image from 'next/image';

<Image
    src="/hero.jpg"
    alt="Hero"
    width={1200}
    height={600}
    priority                              // บอก browser ให้โหลดรูปนี้ก่อน (สำหรับรูปที่อยู่ above the fold = มองเห็นทันทีโดยไม่ต้อง scroll)
/>

// Remote image
<Image src="https://example.com/x.jpg" alt="..." width={400} height={300} />

Next.js จัดให้:

  • responsive srcset
  • WebP/AVIF format
  • lazy load
  • placeholder blur
ts
// next.config.ts
const config = {
    images: {
        remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }]
    }
};

23. next/font — Google Fonts ที่ optimized ​

next/font แก้ปัญหา font ที่ทำให้หน้าโหลดสะดุด ที่มันทำให้:

  • self-host = เก็บ font ใน server ของเราเอง ไม่ต้องโหลดจาก Google ตอน runtime
  • subset = ตัดเอาเฉพาะตัวอักษรที่ใช้ (file เล็กลงมาก)
  • preload = สั่ง browser โหลดล่วงหน้า

ผลคือ:

  • ไม่มี FOIT (Flash Of Invisible Text) — ตัวหนังสือหายไปชั่วครู่ตอนรอ font โหลด ผู้ใช้เห็นพื้นที่ว่างก่อนแล้ว font ปรากฏ
  • ไม่มี FOUT (Flash Of Unstyled Text) — ตัวหนังสือแสดงด้วย font สำรองก่อน แล้วกระพริบเปลี่ยนเป็น font จริงตอนโหลดเสร็จ
  • ไม่มี request ข้ามโดเมน (ไม่ต้องโหลดจาก Google ตอน runtime)
tsx
// app/layout.tsx
import { Inter } from 'next/font/google';
import type { ReactNode } from 'react';

const inter = Inter({ subsets: ['latin'] });

export default function RootLayout({ children }: { children: ReactNode }) {
    return (
        <html className={inter.className}>
            <body>{children}</body>
        </html>
    );
}

→ Self-host, subset, preload — ไม่มี FOIT/FOUT


24. Metadata + SEO ​

จุดที่ Next เหนือ SPA ชัดเจนคือ SEO — export metadata object (หรือ generateMetadata แบบ dynamic) แล้ว Next สร้าง <title>, meta tag, Open Graph (มาตรฐานของ Facebook ที่ social media ใช้แสดง preview ตอนแชร์ link — ครอบคลุม (รองรับ) title, description, image) ใน HTML ฝั่ง server ทำให้ search engine และ social card เห็นครบ ต่างจาก SPA ที่ต้องรอ JS รัน:

tsx
// app/about/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
    title: 'About — My App',
    description: '...',
    openGraph: {
        title: 'About',
        images: ['/og-about.png'],
    },
};

export default function About() { ... }

Dynamic:

tsx
// app/blog/[slug]/page.tsx
// ⭐ Next 15: params เป็น Promise — ต้อง await
type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
    const { slug } = await params;
    const post = await fetchPost(slug);
    return {
        title: post.title,
        description: post.excerpt,
    };
}

24.1 ⚠️ Next 15 Breaking Change — Async Dynamic APIs ​

ใน Next 15 — params, searchParams, cookies(), headers(), draftMode() กลายเป็น Promise (async ทั้งหมด) เพราะ Next ต้องการให้ Partial Prerendering ทำงานได้

tsx
// ❌ Next 14 syntax (sync) — ใช้ใน Next 15 ไม่ได้แล้ว
export default function Page({ params }: { params: { id: string } }) {
    return <div>{params.id}</div>;
}

// ✅ Next 15 syntax (async)
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
    const { id } = await params;
    return <div>{id}</div>;
}

cookies / headers ก็เช่นกัน:

tsx
import { cookies, headers } from 'next/headers';

export default async function Page() {
    const cookieStore = await cookies();       // ⭐ await
    const headersList = await headers();       // ⭐ await

    const token = cookieStore.get('token')?.value;
    const userAgent = headersList.get('user-agent');
    return <div>...</div>;
}

💡 มี codemod อัตโนมัติ: npx @next/codemod@canary next-async-request-api . (ชื่อ tag @canary อาจเปลี่ยนเป็น stable ในเวอร์ชันถัดไป — เช็ค Next.js upgrade guide ก่อนรันจริง) — แปลง project เก่าให้


Part 4.5: Partial Prerendering (PPR) — ⭐ Next 15 Game Changer ​

24.2 ปัญหาก่อน PPR ​

ก่อน Next 15 — เลือก rendering ระดับ page:

  • ทั้งหน้า static → fast แต่ data เก่า
  • ทั้งหน้า dynamic → fresh แต่ทุก request render ใหม่ → ช้า

แต่หน้าจริงปนกัน:

text
HomePage:
  - Header (static)               ← cache ได้
  - Hero banner (static)          ← cache ได้
  - "Hello, {user.name}"          ← dynamic (ต่อ user)
  - Product list (revalidate 60s) ← ISR
  - Cart count                    ← dynamic

→ ก่อน PPR ต้องเลือก 1 mode สำหรับทั้งหน้า

24.3 PPR แก้ปัญหานี้ยังไง ​

⚠️ Status (mid-2026): experimental — PPR ยังอยู่หลัง flag experimental: { ppr: 'incremental' } ใน Next 15. แนวคิดและ architecture stable แต่ flag/default behavior อาจเปลี่ยน. เช็ค Next.js release notes ก่อนใช้ production

Partial Prerendering = สร้าง "static shell" ตอน build + stream "dynamic holes" ตอน request

ลำดับเหตุการณ์จริงที่เกิดขึ้นเมื่อ user โหลดหน้า PPR:

  1. Build time: Next สร้าง HTML ของ <Header> และ <Hero> ล่วงหน้า — นี่คือ "static shell"
  2. User request: server ส่ง shell นั้นไปทันที user เห็น header + hero ใน ~50ms โดยไม่ต้องรอ DB
  3. ขณะเดียวกัน: server รัน <UserGreeting> (ต้องอ่าน cookie) และ <CartCount> (ต้องอ่าน DB) ขนานกันเลย
  4. พอพร้อม: stream HTML ชิ้นใหม่มาแทนที่ <UserSkeleton> และ <CartSkeleton> — user เห็นข้อมูลจริงปรากฏทีละส่วน

SSR ธรรมดา = รอทุกอย่างเสร็จก่อนส่ง PPR = ส่งได้เลยส่วนที่พร้อม ส่วนที่ยังไม่พร้อมค่อย stream ตามมา

tsx
// app/page.tsx
import { Suspense } from 'react';

export const experimental_ppr = true;    // ⭐ opt-in

export default function HomePage() {
    return (
        <>
            <Header />                              {/* static — prerender */}
            <Hero />                                {/* static — prerender */}

            <Suspense fallback={<UserSkeleton />}>
                <UserGreeting />                    {/* dynamic — stream */}
            </Suspense>

            <Suspense fallback={<CartSkeleton />}>
                <CartCount />                       {/* dynamic — stream */}
            </Suspense>
        </>
    );
}

async function UserGreeting() {
    const cookieStore = await cookies();
    const user = await fetchUser(cookieStore.get('token')?.value);
    return <p>Hello, {user.name}</p>;
}

ผลลัพธ์:

  1. User request → Next ส่ง static shell + skeleton ทันที (< 100ms)
  2. Dynamic holes stream มาทีหลังพร้อมข้อมูลจริง
  3. ได้ทั้ง speed + freshness

24.4 enable PPR ทั้ง project ​

ts
// next.config.ts
const config = {
    experimental: {
        ppr: 'incremental',    // 'incremental' = opt-in per route, true = ทั้ง project
    },
};

export default config;

💡 Rule: ทุกอย่างที่ใช้ cookies(), headers(), searchParams, หรือ fetch({ cache: 'no-store' }) = dynamic → ต้องอยู่ใน <Suspense> boundary


Part 4.6: Parallel + Intercepting Routes ​

24.5 Parallel Routes — render หลาย slot พร้อมกัน ​

ปัญหา: dashboard ที่มี main content + sidebar widget ที่โหลด data แยกกัน

slot ใน Parallel Routes = พื้นที่แสดงผลอิสระในหน้าเดียวกัน คล้ายกับ iframe แต่อยู่ใน React tree เดียวกัน folder ที่ขึ้นต้นด้วย @ จะกลายเป็น slot ที่ layout รับเป็น prop

text
app/
└── dashboard/
    ├── @analytics/page.tsx      ← slot 1
    ├── @notifications/page.tsx  ← slot 2
    ├── layout.tsx               ← รับ slot เป็น prop
    └── page.tsx                 ← main content
tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
    children,
    analytics,
    notifications,
}: {
    children: ReactNode;
    analytics: ReactNode;     // ⭐ slot ที่ชื่อ @analytics
    notifications: ReactNode; // ⭐ slot ที่ชื่อ @notifications
}) {
    return (
        <div className="grid grid-cols-3">
            <main className="col-span-2">{children}</main>
            <aside>
                {analytics}
                {notifications}
            </aside>
        </div>
    );
}

→ แต่ละ slot render + stream independent — slot ช้าไม่บล็อก slot อื่น

Pattern: คลิก product → เปิด modal preview แทนการ navigate (แต่ URL เปลี่ยน + share ได้ + refresh = หน้าเต็ม)

text
app/
├── feed/
│   ├── page.tsx                          ← list
│   └── @modal/
│       └── (..)product/[id]/page.tsx     ← intercept /product/[id]
├── product/
│   └── [id]/page.tsx                     ← full page
└── layout.tsx                            ← render @modal slot

(.) (..) (...) คือจำนวนชั้น folder ที่ต้อง "ปีนขึ้นไป" เพื่อหาหน้าจริง:

  • (.) = intercept ระดับเดียวกัน — ไม่ต้องปีน อยู่ชั้นเดียวกับ route ที่ intercept
  • (..) = intercept ขึ้น 1 ระดับ — เหมือนพิมพ์ cd .. ใน terminal เช่น ถ้า feed อยู่ที่ /app/feed/ และ product อยู่ที่ /app/product/ → (..) = ขึ้นออกจาก feed ไปหา product
  • (...) = intercept จาก root — ปีนขึ้นไปถึง root ของ app

ประโยชน์ที่จับต้องได้

โดยไม่มี Intercepting Routes: คลิก product → URL เปลี่ยนเป็น /product/123 → หน้าเว็บเปลี่ยนทั้งหมดเป็นหน้า product

ด้วย Intercepting Routes: คลิก product → URL เปลี่ยนเป็น /product/123 → modal ลอยขึ้นมา แต่หน้า feed ยังอยู่ข้างหลัง กด Refresh หรือแชร์ link → modal หาย เห็นหน้า product เต็มจอแทน ได้ทั้ง UX ดี (modal เปิดเร็ว) และ deep-link ใช้งานได้

→ คลิก Link จาก feed → modal เปิด (URL = /product/123) → Refresh หรือ direct visit → full page render


Part 5: Middleware ​

25. Middleware — code ที่ run ก่อนทุก request ​

ts
// middleware.ts (root)
import { NextResponse, NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
    // req.nextUrl.pathname = path ของ URL ที่ request มา เช่น /dashboard/settings
    const token = req.cookies.get('token')?.value;
    
    if (!token && req.nextUrl.pathname.startsWith('/dashboard')) {
        return NextResponse.redirect(new URL('/login', req.url));
    }
    
    return NextResponse.next();
}

export const config = {
    // matcher = pattern กำหนดว่า middleware ควรทำงานกับ route ไหนบ้าง
    matcher: ['/dashboard/:path*', '/admin/:path*']
};

// ⚠️ ตัวอย่างนี้แสดงเฉพาะโครงสร้าง — production ต้อง verify token จริง
// (เช็ค JWT signature หรือ session ด้วย NextAuth.js / Auth.js) ห้าม trust cookie value โดยไม่ verify

ใช้สำหรับ: auth check, i18n (รองรับหลายภาษา), A/B test, geo redirect

⚠️ Middleware run ที่ edge runtime = V8 (JavaScript engine ของ Chrome/Node.js) isolate (sandbox เล็กๆ ที่รันใกล้ user — เร็วแต่ feature น้อยกว่า Node เต็ม) — ไม่มี Node.js built-in modules ทั้งหมด (fs, path, net, crypto Node API ฯลฯ) และ npm package ที่ใช้ Node API จะ error — ต้องเขียน middleware ให้ใช้เฉพาะ Web Platform APIs (fetch, Request/Response, Web Crypto API เป็นต้น) หรือย้าย logic นั้นไปใช้ใน Route Handler / Server Component แทน


Part 6: API Routes (Route Handlers) ​

ถ้ายังต้องการ API endpoint (เรียกจาก external) — ใช้ Route Handler:

ts
// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { prisma } from '@/lib/db';

export async function GET() {
    const users = await prisma.user.findMany();
    return NextResponse.json(users);
}

export async function POST(req: Request) {
    const body = await req.json();
    // ⚠️ production ต้อง validate input ก่อน pass ให้ DB เสมอ
    const schema = z.object({ name: z.string(), email: z.string().email() });
    const parsed = schema.safeParse(body);
    if (!parsed.success) return NextResponse.json({ error: 'invalid' }, { status: 400 });
    const user = await prisma.user.create({ data: parsed.data });
    return NextResponse.json(user, { status: 201 });
}

→ GET /api/users + POST /api/users ใช้งานได้

ปี 2026 — ใช้ Server Action เป็นหลัก, Route Handler สำหรับกรณีที่ external client เรียก


Part 7: ⚠️ Pitfalls ​

Pitfallแก้
ใส่ 'use client' ทุกที่default = server, ใส่เฉพาะที่ต้อง interactive
Server Component ใช้ useStateไม่ได้ — แยกเป็น Client Component
Pass function จาก Server → Clientไม่ได้ — function ส่งข้าม boundary ไม่ได้ (ยกเว้น Server Action)
Fetch ใน Client Componentใช้ Server Component แทน ถ้าทำได้ — ลด JS
Cache ผิด — data เก่าตั้ง cache: 'no-store' หรือ revalidate
ใช้ window / document ใน Server✗ — แยกเป็น Client + useEffect
Bundle ใหญ่ — ลืม dynamic importdynamic(() => import('./Heavy'))
Next 15: ลืม await params/cookies/headersทุกตัวเป็น Promise — ต้อง await params
Server Action ไม่ check authเรียกได้จากใครก็ได้ — ต้อง auth + validate + authorize ทุก action
PPR — dynamic ไม่อยู่ใน Suspenseทุก dynamic boundary ต้องครอบด้วย <Suspense>
Hydration mismatch (server กับ client render ออกมาไม่ตรงกัน — เช่น Date.now()/Math.random() ค่าคนละค่าระหว่าง 2 ฝั่ง → React warn + UI กระพริบ)ใช้ suppressHydrationWarning บน element (ปิดคำเตือนสำหรับ element นั้น) หรือ render ส่วนที่ต่างกันแบบ client-only (ใส่ใน useEffect หรือ dynamic import with ssr:false)

26. Checkpoint ​

📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย ให้ลองทำเองก่อน ถ้าติดให้ย้อนไปดูตัวอย่างโค้ดในบทแล้วดัดแปลง

🛠️ Checkpoint 11.1 — Blog
ทำ blog ง่าย ๆ:

  • / — list of posts (SSG)
  • /blog/[slug] — post detail (SSG with generateStaticParams)
  • /admin/new — สร้าง post ใหม่ด้วย Server Action

🛠️ Checkpoint 11.2 — Dashboard with Auth

  • /login — form login (Server Action)
  • /dashboard — protected (middleware check cookie)
  • /dashboard/users — list user (Server Component + Prisma)

🛠️ Checkpoint 11.3 — Optimistic UI
ใน /todos ทำ:

  • list todo
  • add/toggle/delete ด้วย Server Action
  • ใช้ useOptimistic hook → update UI ทันที + rollback ถ้า fail

27. สรุปบท ​

✅ Next.js 15 App Router = standard React framework ปี 2026
✅ ทุก component default = Server Component — JS shipped = 0
✅ 'use client' เมื่อต้องการ state / event / hook
✅ Server Components: async, direct DB, no client JS
✅ Server Actions: function call server-side โดยไม่ต้องทำ API — ต้อง auth + validate ทุกตัว
✅ React 19 Actions: useActionState, useFormStatus, useOptimistic — ลด boilerplate
✅ Async APIs (Next 15): params, cookies(), headers() เป็น Promise ทั้งหมด
✅ PPR (Partial Prerendering): รวม static + dynamic — เร็ว + fresh
✅ Parallel + Intercepting Routes: dashboard widget + modal pattern
✅ after(): code ที่ run หลัง response — analytics, logging
✅ SSG / ISR / SSR / CSR / PPR — เลือกตาม use case
✅ Built-in: <Link>, <Image>, next/font, Metadata — optimize ในตัว
✅ Middleware (edge runtime) สำหรับ auth, i18n, redirect
✅ ใช้ Next.js เมื่อ public-facing + SEO, ใช้ Vite + React สำหรับ internal app
✅ Alternative ปี 2026: TanStack Start, React Router v7 framework mode, Waku — Next.js ไม่ใช่ทางเลือกเดียวอีกต่อไป (framework เหล่านี้ทำงานคล้าย Next.js แต่มีแนวทาง/community ต่างกัน — เรียนรู้เพิ่มเติมได้ถ้าสนใจ หลังจากคุ้น Next.js แล้ว)


← บทที่ 10 | บทที่ 12 → React Native