開發紀錄

Next.js 16 App Router 開發心得:5 個讓你少踩坑的技巧

2026年3月18日·8 min read
Next.jsApp RouterTailwindPrisma

這個官網用 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.tsproxy.ts,行為可能不如預期——確認只保留 proxy.ts,並把函式名稱改為 proxy

2. 動態路由的 params 是 Promise

App Router 的 page/layout/route handler 裡,params(以及 searchParams)現在是 Promise,必須 await 後才能讀 slugid 等欄位。這是 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,在資料異動後用 revalidateTagrevalidatePath 失效。這樣才能保留靜態殼層、只刷新那一塊資料。

(Next.js 16 也可改用 Cache Components 的 'use cache' 搭配 cacheTagcacheLife,語意更乾淨,但需要開啟 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,建議:

  1. 使用 driver adapter 搭配連線池,不要用預設的長連線假設。Neon 常見組合是 @prisma/adapter-neon;一般 PostgreSQL 則用 @prisma/adapter-pg 搭配 pg Pool
  2. 在模組頂部加 import 'server-only',避免 client bundle 誤打資料庫。
  3. 開發模式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.tsawait params、Tailwind v4 CSS 設定、Prisma adapter 這幾項若沒一次對齊,很容易在升級後才爆雷。若你在遷移過程遇到卡關,歡迎到聯絡頁留言,我們可以一起對一下設定。