Route Handlers ใช้ไฟล์ app/api/.../route.ts และส่งออกฟังก์ชันชื่อตาม HTTP method (ไม่ใช่ default export แบบ Pages Router)
ใน Next.js 15 ขึ้นไป Route Handlers ไม่ถูก cache โดย default อีกแล้ว ต้องเปิดเองด้วย export const dynamic = 'force-static' หรือใช้ fetch() ที่มี cache
Dynamic Segments อย่าง [id] ใน Next.js 15+ ส่ง params เป็น Promise ต้อง await ก่อนใช้งานเสมอ
ใช้ NextRequest/NextResponse เพื่อเข้าถึง cookies, geolocation และ helper ส่งค่ากลับเป็น JSON ได้สะดวกกว่า Web API มาตรฐาน
เลือกใช้ Route Handlers เมื่อต้องการ public API, webhook, OAuth callback, file upload หรือ SSE/Streaming ส่วน Server Actions เหมาะกับ form submission ภายในแอป
เปิด CORS ด้วยการตั้ง header ใน Response หรือทำ OPTIONS handler แยกสำหรับ preflight
หัวข้อในบทความนี้
Route Handlers คืออะไร และต่างจาก API Routes แบบเดิมยังไง
สร้าง Route Handler ตัวแรก: GET, POST และโครงสร้างไฟล์
Dynamic Route Segments และ params ที่เป็น Promise
ใช้ NextRequest และ NextResponse แทน Web API มาตรฐาน
การ Cache, Revalidate และ Route Segment Config
จัดการ CORS, Headers และ Cookies
ทำ Streaming Response และ Server-Sent Events
Route Handlers ต่างจาก Server Actions ยังไง ควรใช้ตัวไหน
การจัดการ Error และ Status Code ที่ถูกต้อง
Route Handlers คืออะไร และต่างจาก API Routes แบบเดิมยังไง
Route Handlers คือกลไกสร้าง HTTP endpoint บน App Router ที่มาแทนที่ pages/api ของ Pages Router เก่า โดยใช้ไฟล์ชื่อ route.ts วางใน path ใดก็ได้ภายใต้ app/ ผมเขียน Next.js ตั้งแต่ยุค pages-only มา ตอนเปลี่ยนมา App Router ใหม่ๆ ยังติดนิสัยส่ง req/res เป็น handler เดียวอยู่เลย ต้องปรับสมองใหม่ว่าตอนนี้แต่ละ method แยกเป็นฟังก์ชันของตัวเอง
ความแตกต่างหลักจาก Pages Router คือ:
ไม่มี req/res แบบ Node.js ใช้ Web standard Request และ Response (หรือ NextRequest/NextResponse ที่ extend ออกมา) ทำให้โค้ดพอร์ตไป edge หรือ runtime อื่นๆ ได้ตรงๆ
แยก method ออกเป็นฟังก์ชัน ไม่ต้องเช็ค if (req.method === 'POST') แล้ว แต่ละ method เป็น named export ของตัวเอง
วางได้ทุกที่ใน app/ ไม่จำเป็นต้องอยู่ใต้ app/api/ แต่จะอยู่ใน path ที่ collision กับ page.tsx ในระดับเดียวกันไม่ได้
ไม่ cache เป็น default ใน Next.js 15+ ส่วนใน Next.js 13 GET handler เคยถูก cache โดยอัตโนมัติ ตอนนี้ต้องเปิดเองตามที่ระบุใน Next.js 15 release notes
ตำแหน่งไฟล์ที่ใช้บ่อย เช่น app/api/users/route.ts จะ map ไปยัง URL /api/users ส่วน app/api/users/[id]/route.ts map ไป /api/users/123 ตามปกติ การเปลี่ยน method ของ endpoint เดียวกัน ไม่ต้องสร้างไฟล์ใหม่ เพิ่ม named export อีกตัวในไฟล์เดิมได้เลย ทำให้รวม CRUD logic ของหนึ่ง resource ไว้ในที่เดียวค่อนข้างสะอาด
สร้าง Route Handler ตัวแรก: GET, POST และโครงสร้างไฟล์
โครงสร้างพื้นฐานของ Route Handler คือไฟล์ที่ส่งออกฟังก์ชันชื่อตาม HTTP method ที่รองรับ ได้แก่ GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS ทั้งหมดต้องเป็น named export เท่านั้น ไม่ใช่ default export
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
type Post = { id: number; title: string; body: string }
const posts: Post[] = [
{ id: 1, title: 'สวัสดี Next.js 16', body: 'โพสต์แรก' },
]
export async function GET(request: NextRequest) {
// อ่าน query string ผ่าน searchParams
const search = request.nextUrl.searchParams.get('q')
const filtered = search
? posts.filter((p) => p.title.includes(search))
: posts
return NextResponse.json({ posts: filtered })
}
export async function POST(request: NextRequest) {
const body = (await request.json()) as Omit<Post, 'id'>
if (!body.title || !body.body) {
return NextResponse.json(
{ error: 'ต้องระบุ title และ body' },
{ status: 400 },
)
}
const post: Post = { id: posts.length + 1, ...body }
posts.push(post)
return NextResponse.json(post, { status: 201 })
}
ลองเรียกด้วย curl -X POST http://localhost:3000/api/posts -H 'Content-Type: application/json' -d '{"title":"Hello","body":"World"}' ก็จะได้ JSON ตอบกลับพร้อม status 201 ทันที จุดที่ผมพลาดบ่อยตอนเริ่มต้น คือลืม await request.json() แล้วได้ Promise ออกมาแทน object ทำให้ Zod validate ไม่ผ่าน (เสียเวลานั่ง debug อยู่นาน). ถ้าคุณใช้ Zod validate request body ในรูปแบบเดียวกัน ดูได้จากบทความ Server Actions, useActionState และ Zod ที่อธิบาย schema validation ไว้ค่อนข้างละเอียด
คำเตือน: ห้ามวาง route.ts ในโฟลเดอร์เดียวกับ page.tsx เพราะ URL จะชนกัน Next.js จะ throw error ตอน build ทันที แยกใส่ใต้ app/api/ หรือ path แยกต่างหากเสมอ
Dynamic Route Segments และ params ที่เป็น Promise
Dynamic segments ใช้สำหรับ resource ที่มี id เช่น app/api/posts/[id]/route.ts map ไป /api/posts/42 โดยอัตโนมัติ จุดที่เปลี่ยนใน Next.js 15 และยังคงเหมือนเดิมใน Next.js 16 คือ params ถูกเปลี่ยนให้เป็น Promise ทำให้ต้อง await ก่อนใช้งาน ต่างจาก Next.js 14 เดิมที่ส่งเป็น object ตรงๆ
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
type Params = { params: Promise<{ id: string }> }
export async function GET(_req: NextRequest, { params }: Params) {
const { id } = await params // ต้อง await ก่อน
const post = await db.post.findUnique({ where: { id: Number(id) } })
if (!post) {
return NextResponse.json({ error: 'ไม่พบโพสต์' }, { status: 404 })
}
return NextResponse.json(post)
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { id } = await params
await db.post.delete({ where: { id: Number(id) } })
return new NextResponse(null, { status: 204 })
}
สำหรับ catch-all routes เช่น [...slug] ก็เหมือนกัน type จะกลายเป็น Promise<{ slug: string[] }> ส่วน optional catch-all [[...slug]] ส่ง slug เป็น string[] | undefined ครับ อย่าลืม validate input เสมอ เพราะ id ที่มาจาก URL เป็น string เปล่าๆ ไม่มี type safety ถ้ายิง Number(id) ตรงๆ โดยไม่เช็ค จะเจอ NaN ทำลาย query ได้ง่าย
อีกจุดที่หลายคนลืมคือ การใช้ generateStaticParams ร่วมกับ Route Handler ทำได้ในกรณีที่ตั้ง dynamic = 'force-static' และต้องการ pre-render JSON response ตอน build time เช่น API สำหรับ blog ที่ slug list คงที่ อันนี้ช่วยลด cold start บน serverless ได้พอสมควร
ใช้ NextRequest และ NextResponse แทน Web API มาตรฐาน
คุณใช้ Request/Response ของ Web standard ได้ตรงๆ แต่ NextRequest และ NextResponse ที่ extend ออกมา มี helper หลายตัวที่ทำให้ชีวิตง่ายขึ้น
NextRequest เพิ่มอะไร:
request.nextUrl ที่ parse URL แล้วเข้าถึง searchParams, pathname, origin ได้สะดวก
request.cookies สำหรับอ่าน/เขียน cookies แบบ object-style แทนการ parse Cookie header เอง
request.geo และ request.ip ดึง geolocation/IP ของผู้เรียกได้เมื่อ deploy บน Vercel
NextResponse เพิ่มอะไร:
NextResponse.json(data, init) ตอบ JSON พร้อมตั้ง Content-Type อัตโนมัติ
NextResponse.redirect(url, status) redirect แบบ 307/308 ได้ตรงๆ
NextResponse.rewrite(url) rewrite path โดยไม่เปลี่ยน URL bar (ใช้ใน middleware เป็นหลัก)
// อ่าน cookie + ตอบกลับพร้อม set cookie ใหม่
export async function GET(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (!token) {
return NextResponse.json({ error: 'ไม่ได้เข้าสู่ระบบ' }, { status: 401 })
}
const res = NextResponse.json({ user: 'ben' })
res.cookies.set('last-seen', new Date().toISOString(), {
httpOnly: true,
sameSite: 'lax',
path: '/',
})
return res
}
ถ้าคุณทำ authentication อยู่ การจัดการ session cookie ใน Route Handler ทำงานคู่กับ middleware ได้ดี ผมเคยเขียนเรื่อง Auth.js v5 + Google OAuth + RBAC ไว้ ใช้ pattern เดียวกันนี้ในการเซ็ต session cookie ตอน OAuth callback อย่าลืมเปิด httpOnly และ secure ใน production เพื่อกัน XSS ขโมย token
การ Cache, Revalidate และ Route Segment Config
ใน Next.js 15+ มีการเปลี่ยน default พฤติกรรมการ cache ของ Route Handlers สำคัญมาก: GET handlers ไม่ถูก cache โดย default อีกแล้ว ตามเอกสาร Route Handlers อย่างเป็นทางการของ Next.js ถ้าคุณ migrate มาจาก Next.js 13/14 ที่ GET handler ถูก cache อัตโนมัติ อาจต้องเช็คว่ามี endpoint ไหนพึ่งพา cache เก่าอยู่บ้าง
ควบคุม cache ผ่าน Route Segment Config ที่เป็น named exports ระดับ module:
// app/api/posts/route.ts
export const dynamic = 'force-static' // บังคับ cache ตอน build
export const revalidate = 60 // revalidate ทุก 60 วินาที (ISR)
export const runtime = 'edge' // รันบน Edge Runtime
export const fetchCache = 'force-cache'
export async function GET() {
// โค้ดของ handler
}
ค่าที่ใช้ได้ของ dynamic:
'auto' (default) ตัดสินใจตามการใช้งาน dynamic API เช่น cookies(), headers()
'force-dynamic' render ทุก request, ห้าม cache
'force-static' บังคับ static, throw error ถ้าใช้ dynamic API
'error' ผสมระหว่าง force-static และ throw error เมื่อมี dynamic API
การ revalidate แบบ on-demand ผ่าน revalidatePath() หรือ revalidateTag() จาก Route Handler ก็ทำได้ เหมาะกับการสร้าง webhook สำหรับ CMS ที่ trigger ให้ Next.js rebuild หน้าเฉพาะส่วน ตามคำแนะนำใน เอกสาร ISR ของ Vercel
// app/api/revalidate/route.ts — webhook จาก CMS
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
}
const { tag } = await request.json()
revalidateTag(tag)
return NextResponse.json({ revalidated: true, tag })
}
เคล็ดลับ: เปิด runtime = 'edge' เฉพาะ endpoint ที่จำเป็น เช่น geolocation หรือ A/B testing เท่านั้น เพราะ Edge Runtime ใช้ Node API ส่วนใหญ่ไม่ได้ (ห้ามใช้ fs, native database driver) ถ้า endpoint ของคุณคุย Postgres ตรงๆ ผ่าน pg driver จะรันบน Edge ไม่ได้
เมื่อ Route Handler ถูกเรียกจาก origin อื่น (เช่น mobile app, third-party widget) ต้องตั้ง CORS header เอง เพราะ Next.js ไม่ได้เพิ่มให้อัตโนมัติ pattern มาตรฐานคือทำ OPTIONS handler สำหรับ preflight และเพิ่ม header ใน response ของ method อื่นด้วย
// app/api/public/route.ts
const corsHeaders = {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN ?? '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders })
}
export async function GET() {
return NextResponse.json(
{ ok: true },
{ headers: corsHeaders },
)
}
สำหรับ rate limiting และการ block IP มักทำใน proxy.ts (ชื่อใหม่ของ middleware ใน Next.js 16) ดีกว่า เพราะรันก่อนเข้า Route Handler ผมเคยเล่ารายละเอียดไว้ในบทความ Next.js 16 Middleware (proxy.ts) ครอบคลุมเรื่อง rate limit ด้วย Upstash และการ redirect แบบมี condition ครบ ส่วนการอ่าน request header ภายใน Route Handler ใช้ request.headers.get('x-custom') หรือเรียก headers() จาก next/headers ก็ได้ แต่ถ้าใช้ headers() จะกลายเป็น dynamic ทันที
ทำ Streaming Response และ Server-Sent Events
หนึ่งในความสามารถที่ Route Handler ทำได้ดีคือ streaming ตอบ HTTP response เป็นชิ้นๆ โดยไม่ต้องรอจนข้อมูลครบ เหมาะกับ AI chat (LLM streaming), live log, หรือ Server-Sent Events (SSE) สำหรับ realtime notification คุณใช้ ReadableStream ตาม Web Streams API บน MDN ตรงๆ ได้เลย ไม่ต้องพึ่ง library
// app/api/sse/route.ts
export const runtime = 'edge'
export const dynamic = 'force-dynamic'
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 5; i++) {
const data = `data: ${JSON.stringify({ count: i })}\n\n`
controller.enqueue(encoder.encode(data))
await new Promise((r) => setTimeout(r, 1000))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
})
}
ฝั่ง client ใช้ new EventSource('/api/sse') หรือ fetch แล้วอ่านจาก response.body.getReader() ก็ได้ ถ้าคุณคุ้นกับ pattern Suspense streaming อยู่แล้ว ดู Streaming + Suspense ใน Next.js 16 ที่ผมเขียนไว้ จะเห็นว่า concept ใกล้กัน แต่ Suspense ใช้กับ RSC ส่วน Route Handler ใช้กับ HTTP endpoint ดิบๆ
Route Handlers ต่างจาก Server Actions ยังไง ควรใช้ตัวไหน
คำถามที่ผมเจอบ่อยที่สุดตอนสอน Next.js 16 คือ "ใช้ Server Action หรือ Route Handler ดี?" คำตอบสั้นๆ คือ ใช้ Server Action เมื่อโค้ดทั้งหมดอยู่ในแอป Next.js เดียวกัน และเป็นการเปลี่ยนแปลง state (mutation) จาก form หรือ client component ส่วน Route Handler ใช้เมื่อต้องการ HTTP endpoint จริงๆ ให้ระบบนอกเรียก เช่น mobile app, webhook, OAuth callback
เกณฑ์ Route Handlers Server Actions
รูปแบบไฟล์ app/api/.../route.tsฟังก์ชันที่มี 'use server'
เรียกจากระบบนอก ได้ (มี URL จริง) ไม่ได้ (เป็น RPC ภายใน)
HTTP method กำหนดเองครบทุก method POST เท่านั้น (ภายใต้ฝา)
เหมาะกับ Webhook, public API, file upload, SSE Form submission, optimistic update
เข้าถึง URL/params request.nextUrlไม่มี (ต้องส่งเป็น argument)
Progressive enhancement ต้องเขียน fallback เอง ทำงานได้แม้ JS ปิด
Cache & Revalidate Route Segment Config revalidatePath/Tag หลัง action
ในแอปจริง ผมมักใช้ทั้งสองอย่างปนกัน, form ภายในแอปใช้ Server Action ส่วน webhook จาก Stripe/Clerk หรือ public API ให้ mobile app ใช้ Route Handler ทำให้ได้ทั้งความสะดวกของ Server Action กับความยืดหยุ่นของ HTTP endpoint ในแอปเดียว
การจัดการ Error และ Status Code ที่ถูกต้อง
Route Handler ที่ดีต้องคืน HTTP status code ที่สื่อความหมายตรงกับสถานการณ์ ไม่ใช่คืน 200 พร้อม { error: '...' } หมดเหมือนที่เห็นในโค้ดเบสเก่าๆ บ่อยๆ pattern ที่ผมใช้คือ wrap handler ใน try/catch แล้ว map error type ไปยัง status ที่เหมาะสม
import { NextRequest, NextResponse } from 'next/server'
import { ZodError, z } from 'zod'
const schema = z.object({
title: z.string().min(1),
body: z.string().min(1),
})
export async function POST(request: NextRequest) {
try {
const json = await request.json()
const data = schema.parse(json)
const created = await db.post.create({ data })
return NextResponse.json(created, { status: 201 })
} catch (err) {
if (err instanceof ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: err.issues },
{ status: 422 },
)
}
if (err instanceof SyntaxError) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
console.error(err)
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
}
}
Status code ที่ใช้บ่อย:
200 OK สำเร็จ มีเนื้อหาตอบกลับ
201 Created สร้าง resource ใหม่สำเร็จ (POST)
204 No Content สำเร็จแต่ไม่มี body (DELETE)
400 Bad Request input ผิด format
401 Unauthorized ไม่ได้ login
403 Forbidden login แล้วแต่ไม่มีสิทธิ์
404 Not Found ไม่พบ resource
422 Unprocessable Entity input format ถูก แต่ validate ไม่ผ่าน
429 Too Many Requests โดน rate limit
หมายเหตุ: ถ้าคุณ throw error โดยไม่ catch ใน Route Handler, Next.js จะคืน 500 อัตโนมัติ และ log error ใน server ผมแนะนำให้ catch เองทุกครั้งเพื่อ control response body และไม่ให้ stack trace หลุดออกไป production
คำถามที่พบบ่อย
Route Handlers แทน API Routes ของ Pages Router ได้ทั้งหมดเลยไหม?
ได้เกือบทั้งหมด ยกเว้น API ที่พึ่งพา req.socket หรือการ upgrade เป็น WebSocket ตรงๆ ซึ่ง Route Handlers ไม่รองรับ ถ้าต้องการ WebSocket ใน Next.js 16 ยังต้องรัน server แยกหรือใช้บริการ third-party เช่น Pusher, Ably
ทำไม GET handler ของผมไม่ถูก cache เหมือนก่อน?
ตั้งแต่ Next.js 15 เป็นต้นมา GET handler ไม่ถูก cache โดย default อีกแล้ว ถ้าต้องการกลับมาเหมือนเดิม ใส่ export const dynamic = 'force-static' หรือใช้ fetch() ที่กำหนด cache: 'force-cache' ภายใน handler
ใช้ Route Handler กับ Edge Runtime แล้วต่อ database ไม่ได้ ทำยังไง?
Edge Runtime ไม่รองรับ Node native driver เช่น pg, mysql2 ใช้ HTTP-based driver แทน เช่น Neon (@neondatabase/serverless), PlanetScale (@planetscale/database), หรือ Supabase REST API ก็ได้ หรือถ้าไม่จำเป็นต้อง edge ก็ไม่ต้องเปิด runtime นั้น
ตั้ง CORS ใน Route Handler ยังไงให้ปลอดภัย?
อย่าใช้ Access-Control-Allow-Origin: * ใน endpoint ที่ต้องการ credential หรือ session ระบุ origin ที่อนุญาตอย่างเจาะจงผ่าน env var และทำ OPTIONS handler รองรับ preflight ด้วย เพื่อความปลอดภัยเพิ่มเติม validate Origin header ก่อนตอบกลับ
Route Handler รองรับ file upload ขนาดใหญ่ไหม?
รองรับผ่าน request.formData() แต่ต้องระวัง body size limit ของ platform ที่ deploy ถ้าอยู่บน Vercel limit อยู่ที่ 4.5MB สำหรับ Serverless Function ถ้าต้องการอัปโหลดไฟล์ใหญ่ ใช้ presigned URL ของ S3/R2 แล้ว upload ตรงจาก client แทน ส่วน Route Handler ทำหน้าที่ออก URL อย่างเดียว