โหมดมืด
บทที่ 11 — Next.js (App Router, Server Components, Server Actions)
ทำเว็บแบบ full-stack ด้วย React — โหลดเร็ว, SEO ดี, มี server component ในตัว
🔴 ระดับ: สูง — อ่านทีหลังได้ 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 + React | Next.js | |
|---|---|---|
| Type | SPA | full-stack (ครอบคลุมทั้ง frontend + backend ในที่เดียว) |
| SEO | ลำบาก (CSR) | ดี (SSR/SSG) |
| Initial load | ช้ากว่า (download JS ก่อน) | เร็วกว่า (HTML render เสร็จ) |
| Backend ต่อ | external API | built-in + Server Actions |
| Deploy | static 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 dev4. โครงสร้าง 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/settingsConvention:
page.tsx= หน้า route นั้นlayout.tsx= wrap หน้า + child routeloading.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 fetch | useEffect + state | await ใน component |
| JS shipped | ทั้ง component | 0 (แค่ HTML) |
| SEO | bot อาจไม่เห็น data | bot เห็นทุกอย่าง |
| DB access | ผ่าน API endpoint | direct (มี privacy) |
| Initial load | slow (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
| ทำได้ใน | Server | Client |
|---|---|---|
| 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 | เมื่อ | ตัวอย่าง |
|---|---|---|
| SSG | data ไม่เปลี่ยน + same สำหรับทุก user | blog post, landing |
| ISR | data เปลี่ยนช้า ๆ | product catalog |
| SSR | data ต้องสดทุก request | dashboard public, search results |
| CSR | data ผูก user + interactive | private 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 ทุกตัว
ต้องทำเสมอ:
- Auth check ใน action ทุกตัว — อย่า trust
cookies()blindly - Validate input ด้วย Zod เสมอ
- Authorize — user มีสิทธิ์แก้ data นี้จริงไหม
- 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 ใน page | dynamic |
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) และ helperunstable_cacheLife/unstable_cacheTagมี prefixunstable_ที่บอกชัด 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:
- Server ส่ง HTML แรก:
<h1>Dashboard</h1>+ Skeleton 2 ตัว → user เห็นทันที (~100ms) - UserList เสร็จที่ 500ms → server stream HTML ของ UserList → swap Skeleton
- 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 route20.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
21. <Link> — client-side navigation
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:
- Build time: Next สร้าง HTML ของ
<Header>และ<Hero>ล่วงหน้า — นี่คือ "static shell" - User request: server ส่ง shell นั้นไปทันที user เห็น header + hero ใน ~50ms โดยไม่ต้องรอ DB
- ขณะเดียวกัน: server รัน
<UserGreeting>(ต้องอ่าน cookie) และ<CartCount>(ต้องอ่าน DB) ขนานกันเลย - พอพร้อม: 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>;
}ผลลัพธ์:
- User request → Next ส่ง static shell + skeleton ทันที (< 100ms)
- Dynamic holes stream มาทีหลังพร้อมข้อมูลจริง
- ได้ทั้ง 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 contenttsx
// 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 อื่น
24.6 Intercepting Routes — modal ที่ deep-link ได้ (link ตรงไปหน้านั้นได้ + URL เปลี่ยน)
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,cryptoNode 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 import | dynamic(() => 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
- ใช้
useOptimistichook → 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 แล้ว)