Nuxt 4伺服器端路由實戰:SSR優化與Nitro引擎的5個核心模式

前端工程

Nuxt 4伺服器端路由實戰:SSR優化與Nitro引擎的5個核心模式

你的Nuxt應用首屏渲染超過3秒?Hydration Mismatch報錯滿天飛?API路由和頁面路由耦合得像義大利麵?2026年,Nuxt4 + Nitro引擎的伺服器端路由體系已經完全成熟——從檔案路由到混合渲染,從中介軟體認證到邊緣部署,5個核心模式讓你的SSR應用快如閃電。


背景知識

Nuxt 4伺服器端路由核心概念

概念 說明 關鍵API
Nuxt 4 Vue3全端框架,預設SSR defineNuxtConfig
Nitro引擎 伺服器端執行時,支援多平台部署 defineEventHandler
SSR 伺服器端渲染,首屏直出HTML useFetch / useAsyncData
Hybrid Rendering 混合渲染,按路由配置渲染模式 routeRules
Server Routes 檔案式API路由 server/api/ / server/routes/
Server Middleware 伺服器端中介軟體,攔截請求 server/middleware/
Route Rules 路由規則,控制快取與渲染 nitro.routeRules
useFetch 帶SSR支援的資料獲取 useFetch / $fetch
defineEventHandler Nitro事件處理器 defineEventHandler

問題分析

Nuxt SSR的5大核心挑戰

  1. SSR效能瓶頸:伺服器端渲染阻塞、資料串行獲取、大頁面TTFB過高
  2. Hydration錯誤:伺服器端與客戶端渲染不一致、第三方指令碼干擾、時間戳/隨機數導致mismatch
  3. 路由守衛實作:認證中介軟體順序、SSR模式下的重定向、權限校驗時機
  4. API路由設計:檔案路由命名規範、動態參數處理、跨域與錯誤處理
  5. 多環境部署:Node.js/Edge/Serverless差異、環境變數管理、冷啟動最佳化

分步實操

模式1:Nitro伺服器端路由與API設計

// server/api/articles/index.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = Number(query.limit) || 20

  const [articles, total] = await Promise.all([
    db.article.findMany({
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: 'desc' },
      include: { author: { select: { id: true, name: true, avatar: true } } },
    }),
    db.article.count(),
  ])

  return { articles, pagination: { page, limit, total } }
})
// server/api/articles/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')!
  const article = await db.article.findUnique({
    where: { id },
    include: { author: true, tags: true },
  })

  if (!article) {
    throw createError({ statusCode: 404, message: 'Article not found' })
  }

  return article
})
// server/api/articles/index.post.ts
export default defineEventHandler(async (event) => {
  const session = await getUserSession(event)
  if (!session?.user?.id) {
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }

  const body = await readBody(event)
  const validated = articleSchema.parse(body)

  const article = await db.article.create({
    data: {
      title: validated.title,
      content: validated.content,
      authorId: session.user.id,
      tags: { connect: validated.tagIds.map((id: string) => ({ id })) },
    },
  })

  setResponseStatus(event, 201)
  return article
})
// server/routes/sitemap.xml.ts
export default defineEventHandler(async (event) => {
  const articles = await db.article.findMany({
    select: { slug: true, updatedAt: true },
    orderBy: { updatedAt: 'desc' },
  })

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://toolsku.com</loc><changefreq>daily</changefreq></url>
  ${articles.map(a => `<url><loc>https://toolsku.com/article/${a.slug}</loc><lastmod>${a.updatedAt.toISOString()}</lastmod></url>`).join('\n  ')}
</urlset>`

  setResponseHeader(event, 'Content-Type', 'application/xml')
  return sitemap
})

模式2:混合渲染策略(SSR/SSG/SPA/ISR)

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { isr: 3600, headers: { 'Cache-Control': 's-maxage=3600' } },
    '/article/**': { swr: 600 },
    '/blog/**': { isr: 1800 },
    '/tools/**': { prerender: true },
    '/dashboard/**': { ssr: false },
    '/admin/**': { appMiddleware: ['auth'] },
    '/api/**': { cors: true, headers: { 'Cache-Control': 'no-store' } },
  },
  nitro: {
    preset: 'cloudflare-pages',
    compressPublicAssets: true,
    cache: {
      pages: ['/article/**'],
      data: ['/api/articles/**'],
    },
  },
})
<!-- pages/article/[slug].vue -->
<script lang="ts" setup>
const route = useRoute()
const slug = route.params.slug as string

const { data: article, error } = await useFetch(`/api/articles/${slug}`, {
  key: `article-${slug}`,
  transform: (data) => ({
    ...data,
    publishedAt: new Date(data.publishedAt).toLocaleDateString('zh-TW'),
  }),
})

if (error.value) {
  throw createError({ statusCode: 404, message: 'Article not found' })
}

useHead({
  title: () => `${article.value?.title} - 工具庫`,
  meta: [
    { name: 'description', content: () => article.value?.summary || '' },
    { property: 'og:title', content: () => article.value?.title || '' },
  ],
})
</script>

<template>
  <article class="max-w-3xl mx-auto px-4 py-8">
    <h1 class="text-3xl font-bold mb-4">{{ article?.title }}</h1>
    <div class="flex items-center gap-3 text-gray-500 mb-8">
      <img :src="article?.author?.avatar" class="w-8 h-8 rounded-full" alt="" />
      <span>{{ article?.author?.name }}</span>
      <span>{{ article?.publishedAt }}</span>
    </div>
    <div class="prose prose-lg" v-html="article?.content" />
  </article>
</template>

模式3:伺服器端中介軟體與認證

// server/middleware/1.auth.ts
export default defineEventHandler(async (event) => {
  if (!event.path.startsWith('/api/')) return
  if (event.path.startsWith('/api/auth/')) return
  if (event.path.startsWith('/api/public/')) return

  const accessToken = getCookie(event, 'access_token')
  if (!accessToken) {
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }

  try {
    const payload = await verifyAccessToken(accessToken)
    event.context.auth = payload
  } catch {
    throw createError({ statusCode: 401, message: 'Invalid token' })
  }
})
// server/middleware/2.rbac.ts
export default defineEventHandler(async (event) => {
  const auth = event.context.auth
  if (!auth) return

  const role = auth.role as string
  const path = event.path
  const method = event.method

  const permissionMap: Record<string, Record<string, string[]>> = {
    '/api/users': { GET: ['users:read'], POST: ['users:write'], DELETE: ['users:delete'] },
    '/api/articles': { GET: ['content:read'], POST: ['content:write'], DELETE: ['content:delete'] },
    '/api/settings': { GET: ['settings:read'], POST: ['settings:write'] },
  }

  for (const [routePath, methods] of Object.entries(permissionMap)) {
    if (!path.startsWith(routePath)) continue
    const requiredPerms = methods[method]
    if (requiredPerms && !hasAnyPermission(role, requiredPerms)) {
      throw createError({ statusCode: 403, message: 'Forbidden' })
    }
  }
})
// server/middleware/3.rate-limit.ts
const requestCounts = new Map<string, { count: number; resetAt: number }>()

export default defineEventHandler((event) => {
  if (!event.path.startsWith('/api/')) return

  const clientIp = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
  const key = `${clientIp}:${event.path}`
  const now = Date.now()

  const record = requestCounts.get(key)
  if (!record || now > record.resetAt) {
    requestCounts.set(key, { count: 1, resetAt: now + 60_000 })
    return
  }

  record.count++
  if (record.count > 60) {
    setResponseHeader(event, 'Retry-After', String(Math.ceil((record.resetAt - now) / 1000)))
    throw createError({ statusCode: 429, message: 'Too many requests' })
  }
})

模式4:資料獲取與快取策略

// server/api/articles/[slug].get.ts - 伺服器端快取
export default defineEventHandler(defineCachedEventHandler(async (event) => {
  const slug = getRouterParam(event, 'slug')!
  const article = await db.article.findUnique({
    where: { slug },
    include: { author: { select: { name: true, avatar: true } } },
  })

  if (!article) {
    throw createError({ statusCode: 404, message: 'Not found' })
  }

  return article
}, {
  maxAge: 60 * 10,
  staleMaxAge: 60 * 60,
  swr: true,
  getKey: (event) => `article:${getRouterParam(event, 'slug')}`,
}))
<!-- composables/useArticleList.ts -->
<script lang="ts">
export function useArticleList(page: Ref<number>) {
  const { data, pending, error, refresh } = await useFetch('/api/articles', {
    key: `articles-page-${page.value}`,
    query: { page, limit: 20 },
    lazy: true,
    server: false,
    transform: (data: any) => data.articles,
    default: () => [],
    watch: [page],
  })

  return { data, pending, error, refresh }
}
</script>
// server/api/search.get.ts - 串流回應
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const keyword = query.q as string

  if (!keyword) {
    throw createError({ statusCode: 400, message: 'Keyword required' })
  }

  const stream = new ReadableStream({
    async start(controller) {
      const results = await searchArticles(keyword)
      for (const result of results) {
        controller.enqueue(new TextEncoder().encode(JSON.stringify(result) + '\n'))
      }
      controller.close()
    },
  })

  setResponseHeader(event, 'Content-Type', 'text/event-stream')
  return sendStream(event, stream)
})

模式5:生產部署與邊緣渲染

// nuxt.config.ts - 生產配置
export default defineNuxtConfig({
  nitro: {
    preset: process.env.NITRO_PRESET || 'node-cluster',
    compressPublicAssets: { brotli: true },
    minify: true,
    experimental: {
      wasm: true,
      nodeCompat: true,
    },
    rollupConfig: {
      external: ['sharp'],
    },
  },
  runtimeConfig: {
    dbUrl: '',
    redisUrl: '',
    jwtSecret: '',
    public: {
      siteUrl: process.env.SITE_URL || 'http://localhost:3000',
    },
  },
})
// server/plugins/db.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query'] : ['error'],
})

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('close', async () => {
    await prisma.$disconnect()
  })
})

export { prisma }
// server/plugins/redis.ts
import { createClient } from 'redis'

export default defineNitroPlugin(async (nitroApp) => {
  const redis = createClient({ url: process.env.REDIS_URL })
  await redis.connect()

  nitroApp.hooks.hook('close', async () => {
    await redis.quit()
  })
})

避坑指南

坑1:useFetch在SSR中重複請求

❌ 同一個API在伺服器端和客戶端各請求一次

useFetch自動去重,確保key參數唯一;使用useAsyncDatadedupe選項:dedupe: 'defer'

坑2:Hydration Mismatch

❌ 模板中使用Date.now()Math.random(),伺服器端與客戶端渲染結果不一致

✅ 將動態值放入onMounted中賦值,或使用<ClientOnly>包裹;時間戳統一使用ISO格式

坑3:中介軟體執行順序混亂

❌ RBAC中介軟體在Auth中介軟體之前執行,event.context.auth為undefined

✅ 中介軟體檔名加數字前綴:1.auth.ts2.rbac.ts3.rate-limit.ts,Nitro按字典序執行

坑4:routeRules快取策略不當

❌ 所有頁面統一SSR,動態頁面TTFB過高;API路由缺少no-store

✅ 靜態頁面用prerenderisr,動態頁面用swr,API用no-store,Dashboard用ssr: false

坑5:Edge Runtime相容性

❌ 使用Node.js原生模組(fs、path),部署Cloudflare Workers報錯

✅ 使用Nitro的useStorage()替代fs,用nodeCompat相容層,或選擇node-cluster預設


報錯排查

序號 報錯資訊 原因 解決方法
1 Hydration mismatch 伺服器端/客戶端渲染不一致 檢查Date/隨機值,用<ClientOnly>包裹
2 500 API request failed SSR期間API不可達 檢查runtimeConfig中API位址,確保SSR可存取
3 useFetch data is null key重複導致快取命中空值 確保每個useFetchkey唯一
4 429 Too Many Requests SSR渲染觸發API限流 伺服器端請求使用內部URL繞過限流
5 Cannot read property of undefined 中介軟體執行順序錯誤 用數字前綴控制中介軟體順序
6 Module not found: fs Edge Runtime不支援Node API 使用useStorage()nodeCompat
7 CORS error API跨域配置缺失 routeRules中配置cors: true
8 Cache key collision 快取key衝突 使用完整路徑+參數生成唯一key
9 Cold start timeout Serverless冷啟動逾時 預熱函式、減少依賴、使用ISR快取
10 Prisma Client could not be generated 建置時未生成Prisma Client postinstall中新增prisma generate

進階最佳化

1. 串流SSR渲染

// server/routes/preview/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')!
  const stream = renderToStream(await getPreviewData(id))
  return sendStream(event, stream)
})

2. Nitro Storage抽象

// server/utils/storage.ts
export async function getCachedData<T>(key: string, fetcher: () => Promise<T>, ttl = 3600): Promise<T> {
  const cached = await useStorage().getItem<T>(`cache:${key}`)
  if (cached) return cached

  const data = await fetcher()
  await useStorage().setItem(`cache:${key}`, data, { ttl })
  return data
}

3. 請求去重與批次處理

// composables/useDedupedFetch.ts
const inflightRequests = new Map<string, Promise<any>>()

export function useDedupedFetch<T>(url: string, key: string) {
  const existing = inflightRequests.get(key)
  if (existing) return existing

  const promise = $fetch<T>(url).finally(() => inflightRequests.delete(key))
  inflightRequests.set(key, promise)
  return promise
}

4. 邊緣快取策略

// server/middleware/edge-cache.ts
export default defineEventHandler((event) => {
  if (event.path.startsWith('/api/')) return

  setResponseHeader(event, 'CDN-Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400')
  setResponseHeader(event, 'Vary', 'Accept-Encoding')
})

對比分析

維度 Nuxt 4 Next.js 15 SvelteKit Remix
渲染模式 SSR/SSG/SPA/ISR混合 SSR/SSG/ISR SSR/SSG/SPA SSR為主
伺服器端引擎 Nitro(多平台) Next Server Node/Edge Node/Edge
API路由 檔案式server/api/ App Router API +server.ts Resource路由
中介軟體 Nitro Middleware Next Middleware Hooks Loader上下文
邊緣部署 原生支援 Vercel Edge 介面卡模式 介面卡模式
快取策略 routeRules細粒度 fetch cache 手動 Cache-Control
資料獲取 useFetch自動SSR Server Actions load函式 Loader
學習曲線
Vue生態 原生 - - -
生產就緒 2026成熟 成熟 成熟 成熟

總結展望

總結:Nuxt4伺服器端路由的5個核心模式——Nitro檔案路由實現清晰的API設計、混合渲染策略按場景選擇SSR/SSG/SPA/ISR、中介軟體鏈實現認證與權限控制、資料獲取與快取策略提升效能、生產部署與邊緣渲染降低延遲。核心原則:routeRules精細控制渲染模式、中介軟體數字前綴保證執行順序、useFetch的key確保快取唯一、Edge部署避免Node原生模組、ISR+SWR兼顧效能與即時性。建議新專案直接採用Nitro + Cloudflare Pages部署,存量專案逐步遷移混合渲染策略。


線上工具推薦

本站提供瀏覽器本地工具,免註冊即可試用 →

#Nuxt 4#服务端路由#SSR优化#Nitro引擎#全栈开发#2026#前端工程