Linking และ Navigation
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”<Link> จัดการ navigation ส่วนใหญ่ด้วย client-side transition และ prefetch อัตโนมัติ ส่วน useRouter และ helper จาก next/navigation ครอบคลุม navigation แบบ programmatic และฝั่ง server
Link และโหมด prefetch ของมัน
หัวข้อที่มีชื่อว่า “Link และโหมด prefetch ของมัน”<Link> navigate ฝั่ง client โดยไม่ reload หน้าใหม่ทั้งหน้า มันขยายมาจาก native <a> element เลยใช้กับ URL ภายนอกธรรมดาได้ด้วย — แต่ prefetch กับ client-side transition ใช้ได้แค่กับ route ภายใน app ตัวเองเท่านั้น
import Link from 'next/link'
export default function Nav() { return ( <nav> <Link href="/">Home</Link> <Link href="/blog/42">Post 42</Link> <Link href="/dashboard" prefetch={false}>Dashboard</Link> <Link href="https://nextjs.org">External site (plain anchor behavior)</Link> </nav> )}ณ Next.js 16.3.6 prop prefetch ของ App Router รับได้แค่ 3 ค่านี้เท่านั้น (ตรวจกับ docs ที่ bundle มากับเวอร์ชันนี้แล้ว)
| ค่า | พฤติกรรม |
|---|---|
"auto" หรือ null (default) | route แบบ static prefetch เต็ม ๆ ส่วน route แบบ dynamic prefetch แค่ถึง loading.js boundary ที่ใกล้ที่สุด |
true | prefetch route เต็ม ๆ เสมอไม่ว่า static หรือ dynamic — ถ้าเปิด Partial Prefetching ไว้จะรวม App Shell กับ cached content ที่ขึ้นกับ URL ด้วย |
false | ไม่ prefetch เลย — ไม่ทั้งตอนเข้า viewport และไม่ทั้งตอน hover |
route ที่ prefetch แล้วจะเข้าไปอยู่ใน Client Cache การ navigate จึงอาจถูก serve ทันทีจาก RSC payload ที่ cache ไว้แทนที่จะยิง request ใหม่ เรื่อง Client Cache เต็ม ๆ อยู่ใน module Data Fetching
navigation แบบ programmatic ด้วย useRouter
หัวข้อที่มีชื่อว่า “navigation แบบ programmatic ด้วย useRouter”สำหรับ navigation ที่ trigger ด้วย logic เช่นหลัง submit form หลัง timeout หรือหลังเช็ค auth ให้ใช้ useRouter จาก next/navigation ใน Client Component
'use client'
import { useRouter } from 'next/navigation'
export default function SaveButton() { const router = useRouter() async function onSave() { await fetch('/api/save', { method: 'POST' }) router.push('/dashboard') // or replace, back, refresh } return <button onClick={onSave}>Save</button>}router.push เพิ่ม history entry, router.replace แทนที่ entry ปัจจุบัน, router.back ย้อนกลับ, ส่วน router.refresh fetch ข้อมูลใหม่แล้ว re-render Server Component ของ route ปัจจุบัน — มัน clear Client Cache ของ route นั้น แต่ไม่ invalidate server-side cache ใด ๆ เลย (ใช้ revalidatePath/revalidateTag สำหรับฝั่ง server แทน)
อ่าน route ปัจจุบัน
หัวข้อที่มีชื่อว่า “อ่าน route ปัจจุบัน”Client Component อ่าน navigation state ด้วย hook จาก next/navigation ได้แก่ usePathname, useSearchParams และ useParams
'use client'
import { usePathname, useSearchParams, useParams } from 'next/navigation'
export default function Debug() { const pathname = usePathname() // "/blog/42" const searchParams = useSearchParams() // read ?sort=new const params = useParams() // { id: "42" } return <pre>{pathname} {searchParams.get('sort')} {params.id}</pre>}redirect และ notFound ฝั่ง server — และทำไม try/catch ถึงกลืนมัน
หัวข้อที่มีชื่อว่า “redirect และ notFound ฝั่ง server — และทำไม try/catch ถึงกลืนมัน”redirect() กับ permanentRedirect() (ทั้งคู่มาจาก next/navigation) ทำงานด้วยการ throw string digest ของ error ที่ throw ออกมาเข้ารหัสปลายทางกับ status ไว้ในตัวข้อความเลย นี่คือวิธีที่ Next.js จำมันได้ตอนไล่กลับขึ้นไปบน call stack แล้วแปลงเป็น HTTP redirect จริง ๆ แทนที่จะเป็น crash การครอบ call นั้นด้วย try/catch ของเราเองจะดักมันไว้ก่อน Next.js จะเห็น — redirect หายไปเงียบ ๆ
นี่คือ bug รูปแบบเดียวกับที่ module มุตเมชันพิสูจน์ไว้สำหรับ notFound() ข้างใน Server Action (ดู lesson mutations-and-revalidation ของ server-actions-and-mutations ซึ่งพูดถึง unstable_rethrow ด้วย) — นี่คือ bug แบบเดียวกันฝั่ง routing ใน Route Handler ที่เช็ค auth
import { redirect } from 'next/navigation'
// BUG: try/catch swallows redirect()export async function GET(request: Request) { const { searchParams } = new URL(request.url) try { const authed = searchParams.get('authed') === 'true' if (!authed) redirect('/login') return Response.json({ ok: true }) } catch (err) { console.error('redirect-bad caught this instead of letting it redirect:', err) return Response.json({ ok: false, error: 'caught, no real redirect happened' }) }}Build, start, และ request จริง (?authed=false ควรให้ redirect ทำงาน)
GET /api/redirect-bad?authed=false -> HTTP 200{"ok":false,"error":"caught, no real redirect happened"}
server log:redirect-bad caught this instead of letting it redirect: Error: NEXT_REDIRECT digest: 'NEXT_REDIRECT;replace;/login;307;'catch block ทำงาน log error ที่ redirect() throw ออกมาเป๊ะ ๆ แล้ว return JSON 200 ธรรมดาแทนที่จะ redirect เลย digest NEXT_REDIRECT;replace;/login;307; แสดงว่า Next.js คำนวณปลายทาง, ชนิด navigation (replace ค่า default นอก Server Action) และ status 307 ไว้เรียบร้อยแล้ว แต่ไม่มีอันไหนหลุดออกจาก catch เลย วิธีแก้ง่าย ๆ คือเก็บ redirect() ไว้นอก try/catch ที่ไม่ได้ rethrow มันตรง ๆ
import { redirect } from 'next/navigation'
export async function GET(request: Request) { const { searchParams } = new URL(request.url) const authed = searchParams.get('authed') === 'true' if (!authed) redirect('/login') return Response.json({ ok: true })}GET /api/redirect-good?authed=false -> HTTP 307redirect() เรียกตอน render ใน Client Component ได้ด้วย (แต่เรียกใน event handler ไม่ได้ — ใช้ useRouter แทน) และมันรับ absolute URL ได้ด้วยสำหรับ redirect ไปเว็บภายนอก
graph TD N["Need to navigate"] --> UI["From a link or UI?"] N --> LOGIC["From logic in the client?"] N --> SERVER["From the server, or during render?"] UI --> L["Link (prefetch + client nav)"] LOGIC --> R["useRouter push/replace/back/refresh"] SERVER --> S["redirect() / notFound() (throws NEXT_REDIRECT / NEXT_HTTP_ERROR_FALLBACK)"] S -->|"wrapped in try/catch?"| SWALLOW["Silently swallowed unless rethrown"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”redirect() กับ permanentRedirect() ไม่ใช่ฟังก์ชันที่ “return response” — มันคือ exception ที่มี string digest ออกแบบมาเฉพาะ ที่กลไกของ Next.js เองรู้จักตอนไล่ unwind stack ตรงไหนที่เรากังวลว่า unhandled exception จะทำให้ request crash ตรงนั้นควรกังวลพอ ๆ กันว่า try/catch จะดัก error พวกนี้ไว้ก่อนที่มันจะไปถึง Next.js
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| เครื่องมือ | รันที่ไหน | ผลลัพธ์ |
|---|---|---|
<Link prefetch="auto"|null> (default) | Client | prefetch route static เต็ม ๆ ส่วน dynamic แค่ถึง loading.js |
<Link prefetch={true}> | Client | prefetch route เต็ม ๆ เสมอ |
<Link prefetch={false}> | Client | ไม่ prefetch เลย — ทั้ง viewport และ hover |
useRouter().push/replace | Client ข้างใน event handler | client-side navigation, เพิ่ม/แทนที่ history entry |
redirect() / permanentRedirect() | Server หรือตอน render ของ Client Component | throw NEXT_REDIRECT; ถูกกลืนถ้ามี try/catch ที่ไม่ป้องกัน |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “try/catch รอบโค้ดที่อาจ redirect ปลอดภัยกว่าเสมอ” พิสูจน์แล้วด้านบน: มันกลืน redirect 307/308 จริงไปเงียบ ๆ แล้ว return 200 แทน — เหมือนกับกรณี
notFound()ที่ module มุตเมชันพิสูจน์ไว้เป๊ะ ๆ - “
prefetch={false}ยัง prefetch ตอน hover เหมือน docs เก่า” ใน App Router ณ 16.3.6falseแปลว่าไม่เลย — ไม่ทั้ง viewport ไม่ทั้ง hover - “
redirect()ทำงานเหมือนกันไม่ว่าจะเรียกใน event handler ของ Client Component หรือตอน render” ไม่จริง — ใน event handler ต้องใช้useRouter().push/replaceแทน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างทำ navigation ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ
redirect()หรือpermanentRedirect()ถูกเรียกอยู่ในtry/catchที่ไม่ได้ rethrow ไหม นั่นกลืน navigation ไปเลย — พิสูจน์แล้วด้านบนด้วย response 200 จริงแทนที่จะเป็น 307redirect()ถูกเรียกจากข้างใน event handler ของ Client Component (onClickไม่ใช่ตอน render) ไหม ตรงนั้นมันใช้ไม่ได้ — ต้องใช้useRouter().push/replaceแทน- internal navigation ใช้
<a href="/...">ธรรมดาแทน<Link>ไหม มันยังทำงานได้ แต่เสีย client-side transition กับ prefetch ไปฟรี ๆ โดยไม่มีเหตุผล prefetch={false}ถูกใช้ตรงที่จริง ๆ ต้องการ hover-prefetch ไหม ใน App Router ค่านี้ปิด prefetch ทั้งหมดเลย ไม่ใช่แค่ปิดตอน viewport- โค้ดสมมติว่า
router.refresh()invalidate ข้อมูลที่ cache ไว้ฝั่ง server ไหม มัน clear แค่ Client Cache ของ route ปัจจุบันเท่านั้น — ใช้revalidatePath/revalidateTagสำหรับฝั่ง server
🐛 หา bug ในโค้ดนี้
import { redirect } from 'next/navigation'
export async function POST(request: Request) {try { const order = await createOrder(request) if (order.requiresPayment) { redirect('/pay/' + order.id) } return Response.json({ ok: true, order })} catch (err) { console.error('checkout failed', err) return Response.json({ ok: false, error: 'checkout failed' }, { status: 500 })}}เฉลย
redirect() throw error ที่มี digest แบบ NEXT_REDIRECT เพื่อให้ Next.js แปลงเป็น HTTP redirect จริงต่อไปบน stack ที่นี่มันถูกเรียกอยู่ใน try block ที่ catch ของมัน log error อะไรก็ตาม แล้ว return JSON 500 — รวมถึง error ที่ redirect() เพิ่ง throw ด้วย พิสูจน์แล้วด้วย pattern เดียวกัน build และ request จริง: response กลับมาเป็น 200/500 พร้อม JSON body แทนที่จะเป็น 307 redirect ไป /pay/[id] วิธีแก้คือเรียก redirect() หลัง try/catch (ทำ write ข้างใน block ที่ป้องกันไว้ ตัดสินใจและ redirect นอกมัน) หรือ rethrow error รูปแบบ NEXT_REDIRECT ก่อนที่ error handling ของเราเองจะทำงาน