Next.js 16 App Router 開發心得:5 個讓你少踩坑的技巧
這個官網用 Next.js 16 + App Router 從零搭起來,過程中踩過幾個在新版文件裡不太顯眼的坑。以下整理五個實際有用的做法,希望能讓你少花一點晚上除錯的時間。
1. middleware.ts 改名為 proxy.ts(且 runtime 也變了)
Next.js 16 把根目錄的請求攔截層從 middleware.ts 改名為 proxy.ts,匯出的函式名稱也從 middleware 改為 proxy。比改名更關鍵的是 runtime 跟著換了:舊的 middleware 預設跑在 Edge runtime,新的 proxy.ts 則改在 Node.js runtime 上執行。官方的說法是「proxy」更能表達它是擋在 app 前面的網路邊界(network boundary),也避免和 Express.js 那種 middleware 混淆。好消息是 config.matcher 的寫法不變,攔截邏輯多半能原封不動搬過去——但若你原本依賴 Edge 環境的特性,升級時要特別留意。
不想手動改的話,官方有 codemod 可以一次處理檔名與函式名:npx @next/codemod@canary middleware-to-proxy
本站的後台保護就是這樣寫的:只攔 /admin 與 /api/admin,登入頁與登入 API 放行,其餘檢查 session cookie。
// proxy.ts(專案根目錄)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname === '/admin/login' || pathname.startsWith('/api/admin/login')) {
return NextResponse.next()
}
if (pathname.startsWith('/admin')) {
const session = request.cookies.get('admin_session')
if (!session?.value) {
return NextResponse.redirect(new URL('/admin/login', request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/admin/:path*', '/api/admin/:path*'],
}
middleware.ts 目前仍可用於部分 Edge runtime 情境,但已被標記為 deprecated,未來版本會移除。升級後不要同時留著 middleware.ts 與 proxy.ts,行為可能不如預期——確認只保留 proxy.ts,並把函式名稱改為 proxy。
2. 動態路由的 params 是 Promise
App Router 的 page/layout/route handler 裡,params(以及 searchParams)現在是 Promise,必須 await 後才能讀 slug、id 等欄位。這是 Next.js 15 起逐步強制、16 上已相當普遍的模式。
// app/notes/[slug]/page.tsx
export default async function NoteDetailPage({
params,
}: {
readonly params: Promise<{ slug: string }>
}) {
const { slug } = await params
// ...
}
generateMetadata 也要同樣 await params,否則 build 或執行期會出現型別或執行錯誤。
3. 需要即時資料時,明確標記 force-dynamic
預設情況下 Next 會盡量靜態化。像日誌列表這類依賴 Prisma/MDX 合併、內容會變的頁面,應在 page 頂部加上:
export const dynamic = 'force-dynamic'
否則開發環境看起來正常,部署後卻可能快取到舊列表,或 build 時因連不到資料庫而失敗。本站 /notes、/studies、/products 等前台列表都採用這個設定。
要注意 cache: 'no-store' 是 fetch 專屬選項:本站列表走的是 Prisma/MDX 而非 fetch,對這類頁面沒有作用;而且 Next.js 16 的 fetch 預設本來就不快取,no-store 也不是這裡的關鍵。讓你部署後看到舊列表的,是 build 時就被靜態化的 Full Route Cache,而 force-dynamic 正好是針對這一層——這個情境用它是對的,不是將就。
如果你想要比「整頁永遠動態」更細的控制,方向有兩個:
- 時間型 ISR:在 segment 設
export const revalidate = <秒數>,固定間隔重新產生、平常仍吃靜態。(注意revalidate = 0等同強制動態,跟force-dynamic一樣鈍,不算更精準。) - 快取 DB 查詢 + 手動失效:把 Prisma 查詢包進
unstable_cache,在資料異動後用revalidateTag或revalidatePath失效。這樣才能保留靜態殼層、只刷新那一塊資料。
(Next.js 16 也可改用 Cache Components 的 'use cache' 搭配 cacheTag/cacheLife,語意更乾淨,但需要開啟 cacheComponents flag,屬於比較新的路線。)
4. Tailwind v4:用 @plugin 載入外掛
Tailwind v4 改為 CSS-first 設定。Typography、動畫等外掛不再寫在 tailwind.config.js,而是在 globals.css 用 @plugin 引入:
@import "tailwindcss";
@plugin "@tailwindcss/typography";
設計 token 則放在 @theme inline { ... } 或 :root 變數,再透過 var() 給 utility class 使用。MDX 文章區塊加上 prose 類別即可套用排版,不必再維護一份舊版 config 檔。
若從 v3 遷移,先確認 postcss.config 使用 @tailwindcss/postcss,並把原本 plugins 陣列逐一改成 CSS 裡的 @plugin。
5. Neon + Prisma:serverless 連線與開發熱重載
在 serverless 或 Edge 親和的環境(例如 Neon)上使用 Prisma,建議:
- 使用 driver adapter 搭配連線池,不要用預設的長連線假設。Neon 常見組合是
@prisma/adapter-neon;一般 PostgreSQL 則用@prisma/adapter-pg搭配pgPool。 - 在模組頂部加
import 'server-only',避免 client bundle 誤打資料庫。 - 開發模式把
PrismaClient掛在global,避免 Next dev 熱重載每次都 new 一個 client、把連線數耗盡。
// lib/prisma.ts(精簡示意)
import 'server-only'
import { Pool } from 'pg'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@prisma/client'
declare global {
var _prismaClient: PrismaClient | undefined
}
function getClient(): PrismaClient {
if (global._prismaClient) return global._prismaClient
const pool = new Pool({ connectionString: process.env.DATABASE_URL! })
const adapter = new PrismaPg(pool)
const client = new PrismaClient({ adapter })
if (process.env.NODE_ENV !== 'production') {
global._prismaClient = client
}
return client
}
export const prisma = new Proxy({} as PrismaClient, {
get(_, prop) {
return getClient()[prop as keyof PrismaClient]
},
})
本站用 Proxy 包一層,目的是延遲到第一次存取才建立 client,避免模組被 import 的當下就觸發連線(對 serverless 冷啟動與測試環境比較友善)。每次屬性存取都會經過 getClient(),但因為內部有 global 快取,實際開銷可以忽略。若你不需要這層延遲,直接 export const prisma = global._prismaClient ?? getClient() 對多數專案更直覺,不必到處複製 Proxy 寫法。
讀寫失敗時(例如本機沒設 DATABASE_URL),前台頁面最好用 try/catch 降級到 MDX 或靜態內容,不要讓整頁 500——本站日誌詳情就是先查 DB,沒有再讀 content/notes/*.mdx。
結語
Next.js 16 的改動不算翻天覆地,但 proxy.ts、await params、Tailwind v4 CSS 設定、Prisma adapter 這幾項若沒一次對齊,很容易在升級後才爆雷。若你在遷移過程遇到卡關,歡迎到聯絡頁留言,我們可以一起對一下設定。