Nuxt 4サーバールーティング:NitroエンジンによるSSR最適化の5つのコアパターン
Nuxt 4サーバールーティング:NitroエンジンによるSSR最適化の5つのコアパターン
Nuxtアプリの初回レンダリングが3秒超え?Hydration Mismatchエラーが大量発生?APIルートとページルートがスパゲッティ状態?2026年、Nuxt 4 + Nitroエンジンのサーバールーティングシステムは完全に成熟しました——ファイルベースルーティングからハイブリッドレンダリング、ミドルウェア認証からエッジデプロイまで、5つのコアパターンでSSRアプリを超高速化。
背景知識
Nuxt 4サーバールーティングのコア概念
| 概念 | 説明 | 主要API |
|---|---|---|
| Nuxt 4 | Vue 3フルスタックフレームワーク、デフォルト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つのコア課題
- SSRパフォーマンスボトルネック:サーバーレンダリングのブロック、データの直列フェッチ、大規模ページの高TTFB
- Hydrationエラー:サーバー/クライアントのレンダリング不一致、サードパーティスクリプトの干渉、タイムスタンプ/乱数によるmismatch
- ルートガードの実装:認証ミドルウェアの順序、SSRモードでのリダイレクト、権限チェックのタイミング
- APIルート設計:ファイルルーティングの命名規則、動的パラメータ処理、CORSとエラーハンドリング
- マルチ環境デプロイ: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('ja-JP'),
}),
})
if (error.value) {
throw createError({ statusCode: 404, message: 'Article not found' })
}
useHead({
title: () => `${article.value?.title} - ToolsKu`,
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パラメータを一意に設定。useAsyncDataのdedupe: 'defer'を活用
落とし穴2:Hydration Mismatch
❌ テンプレートでDate.now()やMath.random()を使用し、サーバー/クライアントのレンダリング結果が不一致
✅ 動的値はonMountedで代入、または<ClientOnly>でラップ。タイムスタンプはISO形式に統一
落とし穴3:ミドルウェアの実行順序が混乱
❌ RBACミドルウェアがAuthミドルウェアより先に実行され、event.context.authがundefined
✅ ミドルウェアファイル名に数値プレフィックスを付与:1.auth.ts、2.rbac.ts、3.rate-limit.ts。Nitroは辞書順で実行
落とし穴4:routeRulesのキャッシュ戦略が不適切
❌ 全ページ一律SSR、動的ページのTTFBが高い。APIルートにno-storeが未設定
✅ 静的ページはprerenderまたはisr、動的ページはswr、APIはno-store、ダッシュボードは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 URLを確認、SSRからのアクセスを確保 |
| 3 | useFetch data is null |
key重複でキャッシュがnullを返す | 各useFetchのkeyを一意に設定 |
| 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のCORS設定が不足 | routeRulesでcors: trueを設定 |
| 8 | Cache key collision |
キャッシュキーの衝突 | フルパス+パラメータで一意キーを生成 |
| 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 |
リソースルート |
| ミドルウェア | Nitro Middleware | Next Middleware | Hooks | Loaderコンテキスト |
| エッジデプロイ | ネイティブ対応 | Vercel Edge | アダプターモード | アダプターモード |
| キャッシュ戦略 | routeRulesきめ細かい制御 | fetch cache | 手動 | Cache-Control |
| データフェッチ | useFetch自動SSR | Server Actions | load関数 | Loader |
| 学習曲線 | 低 | 中 | 低 | 中 |
| Vueエコシステム | ネイティブ | - | - | - |
| 本番対応 | 2026成熟 | 成熟 | 成熟 | 成熟 |
まとめと展望
まとめ:Nuxt 4サーバールーティングの5つのコアパターン——NitroファイルルーティングでクリーンなAPI設計、ハイブリッドレンダリング戦略でシーン別にSSR/SSG/SPA/ISRを選択、ミドルウェアチェーンで認証と権限制御、データフェッチとキャッシュ戦略でパフォーマンス向上、本番デプロイとエッジレンダリングでレイテンシ削減。基本原則:routeRulesでレンダリングモードをきめ細かく制御、ミドルウェアに数値プレフィックスで実行順序を保証、useFetchのkeyでキャッシュの一意性を確保、EdgeデプロイではNodeネイティブモジュールを回避、ISR+SWRでパフォーマンスと鮮度を両立。新規プロジェクトはNitro + Cloudflare Pagesデプロイを推奨、既存プロジェクトは段階的にハイブリッドレンダリング戦略に移行。
オンラインツール推奨
- JSONフォーマッター(APIデバッグ):/ja/json/format
- ハッシュ計算(署名検証):/ja/encode/hash
- cURL→コード変換(APIテスト):/ja/dev/curl-to-code
ブラウザローカルツールを無料で試す →