Vue 3サーバーコンポーネント実践:Islandsアーキテクチャからプロダクション級SSRまで5つのパターン
SPAのパフォーマンスのジレンマ:なぜサーバーコンポーネントが必要なのか
2026年のフロントエンドアプリケーションはますます複雑化しているが、SPAのパフォーマンスボトルネックはさらに目立つようになっている。典型的なVue 3 SPAアプリケーションのLighthouseスコアはしばしば残念な結果になる:TTFBが1.5秒を超え、FCPが2秒以上で止まり、LCPが日常的に3〜4秒に達する。根本的な原因は——アプリケーション全体がJavaScriptのダウンロード、解析、実行が完了するまで待たなければページのレンダリングが始まらないことにある。
さらに致命的なのはハイドレーションコストである:中規模のVueアプリケーションのコンポーネントツリーのハイドレーションプロセスは500ms〜1sのCPU時間を消費し、ローエンドのモバイルデバイスでは2〜3秒に達することもある。ユーザーはページがレンダリングされたのを見ているが、ボタンをクリックしても反応しない——これがイライラさせる「ハイドレーションギャップ」である。
サーバーコンポーネント(Server Components)の登場は、まさにこれらの問題を根本的に解決するためのものである:インタラクションを必要としないコンポーネントは永遠にサーバー側に留め、インタラクティブなコンポーネントだけをクライアントに送る。
コア概念クイックリファレンス
| 概念 | 説明 |
|---|---|
| Server Components | サーバー側でレンダリングし、JavaScriptをクライアントに送信しないコンポーネント、ゼロハイドレーションコスト |
| Client Components | インタラクションロジックを含むコンポーネント、ユーザー操作に応答するためにハイドレーションが必要 |
| Islands Architecture | インタラクティブな領域のみハイドレーションし、残りは静的HTMLとして保持するアーキテクチャパターン |
| Hydration | サーバーレンダリングされた静的HTMLとクライアント側JavaScriptの状態を関連付けるプロセス |
| Streaming SSR | チャンク単位でHTMLを転送し、ブラウザが完全なレスポンスを待たずに段階的にレンダリングできるようにする |
| Selective Hydration | ユーザーが操作しているコンポーネントのみ優先的にハイドレーションし、全体のハイドレーションを行わない |
💡 JSONフォーマッターツールを使用してVueコンポーネントのprops受け渡し構造を検証し、サーバーとクライアントコンポーネント間のデータシリアライズが正しいことを確認。
5つのコアチャレンジ
- ハイドレーションウォーターフォール:フルハイドレーションがJS実行でメインスレッドをブロックし、TTIが著しく劣化。ローエンドデバイスではインタラクション遅延が数秒に達する
- バンドルサイズの膨張:サーバー専用の依存関係(データベースドライバ、Markdownパーサー)が誤ってクライアントバンドルに含まれる
- データフェッチウォーターフォール:コンポーネントのネストによりデータリクエストが直列化され、親コンポーネントのデータ取得完了後、子コンポーネントがリクエストを開始できる
- 初回レンダリングのブロック:従来のSSRはすべてのデータが準備できるまで最初のバイトを送信できず、TTFBが遅いクエリに引きずられる
- サーバー/クライアントコンポーネントの境界が曖昧:どのロジックをサーバーに置き、どれをクライアントに置くべきか開発者が判断しづらく、アーキテクチャが混乱する
パターン1:基本的なサーバーコンポーネントとdefineAsyncComponent
最も基本的なパターン:サーバー側でデータフェッチを完了し、レンダリング結果を静的HTMLとして転送し、クライアント側のハイドレーションコストをゼロにする。
サーバーコンポーネント:データベースへの直接アクセス
<!-- components/ArticleList.server.vue -->
<template>
<section class="max-w-4xl mx-auto px-4">
<h2 class="text-2xl font-bold text-gray-900 mb-6">最新記事</h2>
<div class="space-y-4">
<article
v-for="article in articles"
:key="article.id"
class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
>
<h3 class="text-lg font-semibold text-gray-800">{{ article.title }}</h3>
<p class="text-gray-600 mt-2 line-clamp-2">{{ article.excerpt }}</p>
<div class="flex items-center gap-2 mt-3 text-sm text-gray-500">
<span>{{ article.author }}</span>
<span>·</span>
<time :datetime="article.publishedAt">{{ formatDate(article.publishedAt) }}</time>
</div>
</article>
</div>
</section>
</template>
<script lang="ts">
import { db } from '~/server/database'
interface Article {
id: number
title: string
excerpt: string
author: string
publishedAt: string
}
export default defineComponent({
async setup() {
const articles: Article[] = await db.query(
'SELECT id, title, excerpt, author, published_at as "publishedAt" FROM articles WHERE status = $1 ORDER BY published_at DESC LIMIT 10',
['published']
)
const formatDate = (dateStr: string): string => {
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(dateStr))
}
return { articles, formatDate }
}
})
</script>
defineAsyncComponentによる遅延読み込み
<!-- pages/blog/index.vue -->
<template>
<div class="min-h-screen bg-gray-50">
<SiteHeader />
<ArticleList />
<SiteFooter />
</div>
</template>
<script lang="ts">
export default defineComponent({
components: {
ArticleList: defineAsyncComponent(() =>
import('~/components/ArticleList.server.vue')
)
}
})
</script>
ポイント
.server.vueサフィックスのコンポーネントはサーバー側でのみレンダリングされ、そのJavaScriptはクライアントバンドルに含まれない- サーバーコンポーネントはデータベースドライバ、ファイルシステムなどのNode.jsモジュールを直接
importでき、バンドルサイズを気にする必要がない defineAsyncComponentはSSRシナリオでは非同期setupの完了を待ち、クライアント側ではオンデマンドで読み込む
パターン2:Islandsアーキテクチャ——サーバーとクライアントコンポーネントの混合
IslandsアーキテクチャはVue 3サーバーコンポーネントの最もコアとなる実践パターンである:ページ本体はサーバーコンポーネントによって静的HTMLとしてレンダリングされ、インタラクションが必要な「アイランド」領域にのみクライアントコンポーネントが注入される。
Islandsページレイアウト
<!-- pages/index.vue -->
<template>
<div class="min-h-screen bg-white">
<SiteHeader />
<main>
<HeroSection.server />
<div class="max-w-7xl mx-auto px-4 py-12">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<ProductGrid.server />
</div>
<aside class="space-y-6">
<ShoppingCart.client />
<NewsletterSignup.client />
<RecentPosts.server />
</aside>
</div>
</div>
<LazyLiveChat.client />
</main>
<SiteFooter.server />
</div>
</template>
クライアントアイランドコンポーネント
<!-- components/ShoppingCart.client.vue -->
<template>
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4">
<h3 class="font-semibold text-gray-900 mb-3">ショッピングカート</h3>
<div v-if="cartItems.length === 0" class="text-gray-500 text-sm py-4 text-center">
カートは空です
</div>
<div v-else class="space-y-3">
<div
v-for="item in cartItems"
:key="item.id"
class="flex items-center justify-between text-sm"
>
<span class="text-gray-700 truncate">{{ item.name }}</span>
<div class="flex items-center gap-2">
<button
class="w-6 h-6 rounded bg-gray-200 hover:bg-gray-300 flex items-center justify-center"
@click="decrementItem(item.id)"
>
-
</button>
<span class="w-6 text-center">{{ item.quantity }}</span>
<button
class="w-6 h-6 rounded bg-gray-200 hover:bg-gray-300 flex items-center justify-center"
@click="incrementItem(item.id)"
>
+
</button>
</div>
</div>
<div class="border-t border-gray-200 pt-3 flex justify-between font-semibold">
<span>合計</span>
<span class="text-blue-600">¥{{ totalPrice.toLocaleString() }}</span>
</div>
</div>
</div>
</template>
<script lang="ts">
interface CartItem {
id: number
name: string
price: number
quantity: number
}
export default defineComponent({
setup() {
const cartItems = ref<CartItem[]>([])
const totalPrice = computed(() =>
cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const incrementItem = (id: number) => {
const item = cartItems.value.find(i => i.id === id)
if (item) item.quantity++
}
const decrementItem = (id: number) => {
const item = cartItems.value.find(i => i.id === id)
if (item) {
if (item.quantity > 1) item.quantity--
else cartItems.value = cartItems.value.filter(i => i.id !== id)
}
}
return { cartItems, totalPrice, incrementItem, decrementItem }
}
})
</script>
Islandsアーキテクチャのパフォーマンス改善
従来のSSRページハイドレーション:
┌──────────────────────────────────────────┐
│ フルハイドレーション(500ms - 2s) │
│ [Header] [Hero] [Grid] [Cart] [Footer] │
└──────────────────────────────────────────┘
Islandsアーキテクチャのハイドレーション:
┌──────────────────────────────────────────┐
│ 静的HTML(ゼロJS) │
│ [Header] [Hero] [Grid] [Footer] │
│ ↓ │
│ [Cartアイランドハイドレーション 50ms] │
│ [Signupアイランドハイドレーション 30ms]│
└──────────────────────────────────────────┘
ハイドレーション時間が500ms+から80msに削減、84%の改善
💡 Base64エンコードツールを使用して、サーバーからクライアントコンポーネントに渡す初期状態データをエンコードし、安全な転送を確保。
パターン3:Streaming SSRとSuspenseバウンダリ
Streaming SSRにより、ページは最も遅いコンポーネントにブロックされなくなる——速いデータを先に送信し、遅いデータは後から送信し、ブラウザが段階的にレンダリングする。
Suspenseバウンダリ分割戦略
<!-- pages/dashboard.vue -->
<template>
<div class="min-h-screen bg-gray-100">
<DashboardHeader />
<main class="max-w-7xl mx-auto px-4 py-6 space-y-6">
<Suspense>
<template #default>
<StatsOverview.server />
</template>
<template #fallback>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="bg-white rounded-lg p-6 animate-pulse">
<div class="h-4 bg-gray-200 rounded w-1/2 mb-3" />
<div class="h-8 bg-gray-200 rounded w-3/4" />
</div>
</div>
</template>
</Suspense>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Suspense>
<template #default>
<RevenueChart.server />
</template>
<template #fallback>
<ChartSkeleton />
</template>
</Suspense>
<Suspense>
<template #default>
<UserActivity.server />
</template>
<template #fallback>
<ChartSkeleton />
</template>
</Suspense>
</div>
<Suspense>
<template #default>
<RecentTransactions.server />
</template>
<template #fallback>
<TableSkeleton :rows="5" />
</template>
</Suspense>
</main>
</div>
</template>
ストリーミングデータフェッチコンポーネント
<!-- components/RevenueChart.server.vue -->
<template>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">売上推移</h3>
<div class="h-64 flex items-end gap-1">
<div
v-for="(item, index) in revenueData"
:key="index"
class="flex-1 bg-blue-500 rounded-t transition-all hover:bg-blue-600"
:style="{ height: `${(item.value / maxValue) * 100}%` }"
:title="`${item.label}: ¥${item.value.toLocaleString()}`"
/>
</div>
<div class="flex justify-between mt-2 text-xs text-gray-500">
<span>{{ revenueData[0]?.label }}</span>
<span>{{ revenueData[revenueData.length - 1]?.label }}</span>
</div>
</div>
</template>
<script lang="ts">
import { db } from '~/server/database'
interface RevenueItem {
label: string
value: number
}
export default defineComponent({
async setup() {
const revenueData: RevenueItem[] = await db.query(`
SELECT
TO_CHAR(date, 'YYYY-MM') as label,
SUM(amount) as value
FROM transactions
WHERE date >= NOW() - INTERVAL '12 months'
GROUP BY TO_CHAR(date, 'YYYY-MM')
ORDER BY label ASC
`)
const maxValue = Math.max(...revenueData.map(d => d.value))
return { revenueData, maxValue }
}
})
</script>
Nuxt 4 ストリーミングSSR設定
export default defineNuxtConfig({
future: {
compatibilityVersion: 4
},
ssr: true,
experimental: {
componentIslands: true,
asyncContext: true,
viewTransition: true
},
nitro: {
preset: 'node-cluster',
compressPublicAssets: { brotli: true },
experimental: {
wasm: true,
database: true
}
},
routeRules: {
'/dashboard': { ssr: true },
'/dashboard/analytics': { swr: 300 },
'/dashboard/realtime': { ssr: true }
}
})
パターン4:データフェッチ戦略——サーバー側 vs クライアント側
サーバー側でデータをフェッチすべきか、クライアント側でフェッチすべきかを理解することは、高性能なハイブリッドアプリケーションを構築する鍵である。
4つのデータフェッチ戦略の比較
| 戦略 | ユースケース | TTFBへの影響 | クライアントJS | インタラクティブ性 |
|---|---|---|---|---|
| サーバー直接クエリ | 静的コンテンツ、SEO重要ページ | 高い(クエリ待ち) | ゼロ | なし |
| useFetch SSR | クライアントインタラクションが必要なデータ | 中(デュアルフェッチ) | 低い | あり |
| クライアントuseFetch | ユーザーアクションでトリガーされるデータ | 影響なし | 中 | 強い |
| ハイブリッド戦略 | 複雑なページ、部分的に静的/動的 | 低い | 中 | 混合 |
サーバー直接クエリ(ゼロクライアントJS)
<!-- components/StaticContent.server.vue -->
<template>
<div class="prose max-w-none" v-html="renderedContent" />
</template>
<script lang="ts">
import { readFile } from 'node:fs/promises'
import { renderMarkdown } from '~/server/utils/markdown'
export default defineComponent({
async setup() {
const rawContent = await readFile('./content/about.md', 'utf-8')
const renderedContent = renderMarkdown(rawContent)
return { renderedContent }
}
})
</script>
useFetch SSRパターン(両環境で共有)
<!-- pages/products/[id].vue -->
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<div v-if="pending" class="animate-pulse space-y-4">
<div class="h-8 bg-gray-200 rounded w-1/3" />
<div class="h-64 bg-gray-200 rounded" />
</div>
<div v-else-if="error" class="text-center py-12">
<p class="text-red-600">読み込み失敗:{{ error.message }}</p>
<button
class="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
@click="refresh()"
>
再試行
</button>
</div>
<template v-else-if="data">
<ProductDetail :product="data" />
<RelatedProducts :category-id="data.categoryId" />
</template>
</div>
</template>
<script lang="ts">
interface Product {
id: number
name: string
price: number
description: string
categoryId: number
images: string[]
}
export default defineComponent({
setup() {
const route = useRoute()
const { data, pending, error, refresh } = useFetch<Product>(
`/api/products/${route.params.id}`,
{
key: `product-${route.params.id}`,
default: () => null,
watch: [() => route.params.id]
}
)
return { data, pending, error, refresh }
}
})
</script>
クライアント側オンデマンドフェッチ(インタラクション駆動)
<!-- components/ProductReviews.client.vue -->
<template>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">ユーザーレビュー</h3>
<button
class="text-sm text-blue-600 hover:text-blue-800"
@click="loadMore"
>
{{ hasMore ? 'もっと読み込む' : 'すべて読み込み済み' }}
</button>
</div>
<div class="space-y-4">
<ReviewCard
v-for="review in reviews"
:key="review.id"
:review="review"
/>
</div>
<div v-if="isLoadingMore" class="text-center py-4">
<span class="text-gray-500">読み込み中...</span>
</div>
</div>
</template>
<script lang="ts">
interface Review {
id: number
userName: string
rating: number
content: string
createdAt: string
}
export default defineComponent({
props: {
productId: { type: Number, required: true }
},
setup(props) {
const reviews = ref<Review[]>([])
const page = ref(1)
const hasMore = ref(true)
const isLoadingMore = ref(false)
const loadMore = async () => {
if (isLoadingMore.value || !hasMore.value) return
isLoadingMore.value = true
try {
const { data } = await useFetch<Review[]>(
`/api/products/${props.productId}/reviews`,
{ query: { page: page.value, pageSize: 10 } }
)
if (data.value && data.value.length > 0) {
reviews.value.push(...data.value)
page.value++
} else {
hasMore.value = false
}
} finally {
isLoadingMore.value = false
}
}
onMounted(() => loadMore())
return { reviews, hasMore, isLoadingMore, loadMore }
}
})
</script>
ハイブリッド戦略:サーバープリフェッチ + クライアントインクリメンタル
<!-- pages/search.vue -->
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<SearchBar.client
:initial-query="route.query.q as string"
@search="handleSearch"
/>
<div class="mt-6">
<Suspense>
<template #default>
<SearchResults.server :query="currentQuery" />
</template>
<template #fallback>
<SearchResultsSkeleton />
</template>
</Suspense>
</div>
</div>
</template>
<script lang="ts">
export default defineComponent({
setup() {
const route = useRoute()
const router = useRouter()
const currentQuery = ref(route.query.q as string || '')
const handleSearch = (query: string) => {
currentQuery.value = query
router.push({ query: { q: query } })
}
return { route, currentQuery, handleSearch }
}
})
</script>
パターン5:プロダクション級デプロイ——Nuxt 4サーバーコンポーネント
これまでの4つのパターンをプロダクション級のNuxt 4プロジェクトに統合し、設定、デプロイ、モニタリングをカバーする。
完全なプロジェクト構造
vue3-sc-app/
├── app/
│ ├── pages/
│ │ ├── index.vue
│ │ ├── products/
│ │ │ ├── index.vue
│ │ │ └── [id].vue
│ │ ├── blog/
│ │ │ ├── index.vue
│ │ │ └── [slug].vue
│ │ └── dashboard.vue
│ ├── components/
│ │ ├── ProductGrid.server.vue
│ │ ├── ProductCard.vue
│ │ ├── ShoppingCart.client.vue
│ │ ├── SearchBar.client.vue
│ │ ├── NewsletterSignup.client.vue
│ │ ├── RevenueChart.server.vue
│ │ └── SiteHeader.server.vue
│ ├── composables/
│ │ ├── useCart.ts
│ │ ├── useSearch.ts
│ │ └── useAnalytics.ts
│ ├── layouts/
│ │ └── default.vue
│ └── app.vue
├── server/
│ ├── api/
│ │ ├── products/
│ │ │ ├── index.ts
│ │ │ └── [id].ts
│ │ ├── search.ts
│ │ └── cart.ts
│ ├── middleware/
│ │ ├── auth.ts
│ │ └── rateLimit.ts
│ ├── database.ts
│ └── utils/
│ ├── markdown.ts
│ └── cache.ts
├── public/
├── nuxt.config.ts
├── package.json
└── tsconfig.json
プロダクション級Nuxt設定
import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
future: {
compatibilityVersion: 4
},
ssr: true,
routeRules: {
'/': { prerender: true },
'/products/**': { swr: 3600 },
'/blog/**': { isr: 600 },
'/dashboard': { ssr: true },
'/admin/**': { ssr: false },
'/api/**': { cors: true }
},
nitro: {
preset: 'node-cluster',
compressPublicAssets: { brotli: true },
cacheDir: '.nitro-cache',
experimental: {
wasm: true,
database: true
},
rollupConfig: {
external: ['pg', 'sharp', 'better-sqlite3']
}
},
experimental: {
componentIslands: true,
asyncContext: true,
viewTransition: true,
typedPages: true
},
modules: [
'@nuxtjs/tailwindcss',
'@nuxt/image',
'@nuxt/fonts'
],
image: {
quality: 80,
formats: ['avif', 'webp']
},
app: {
head: {
charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1',
titleTemplate: '%s | Vue3 SC App'
}
}
})
サーバーAPIルート
import { defineEventHandler, getQuery, createError } from 'h3'
import { db } from '~/server/database'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 24
const category = query.category as string | undefined
const offset = (page - 1) * pageSize
const whereClause = category
? 'WHERE p.status = $1 AND p.category = $2'
: 'WHERE p.status = $1'
const params = category
? ['published', category]
: ['published']
const [products, countResult] = await Promise.all([
db.query(
`SELECT id, name, price, image_url, category
FROM products p
${whereClause}
ORDER BY created_at DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, pageSize, offset]
),
db.query(
`SELECT COUNT(*) as total FROM products p ${whereClause}`,
params
)
])
return {
items: products,
total: Number(countResult[0].total),
page,
pageSize,
totalPages: Math.ceil(Number(countResult[0].total) / pageSize)
}
})
Dockerプロダクションデプロイ
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.output ./.output
ENV HOST=0.0.0.0
ENV PORT=3000
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
ヘルスチェックとモニタリング
import { defineEventHandler } from 'h3'
export default defineEventHandler(async () => {
const startTime = Date.now()
try {
await db.query('SELECT 1')
return {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
database: 'connected',
responseTime: `${Date.now() - startTime}ms`
}
} catch (error) {
throw createError({
statusCode: 503,
statusMessage: 'Service Unavailable',
data: {
status: 'unhealthy',
database: 'disconnected',
responseTime: `${Date.now() - startTime}ms`
}
})
}
})
5つのよくある落とし穴
落とし穴1:サーバーコンポーネントでクライアントAPIを使用する
❌ 間違った書き方:
<!-- components/BrokenCounter.server.vue -->
<template>
<div>
<p>カウント:{{ count }}</p>
<button @click="count++">増加</button>
</div>
</template>
<script lang="ts">
export default defineComponent({
setup() {
const count = ref(0)
onMounted(() => {
console.log('mounted') // 実行されない
})
return { count }
}
})
</script>
✅ 正しい書き方:
<!-- components/Counter.client.vue -->
<template>
<div>
<p>カウント:{{ count }}</p>
<button @click="count++">増加</button>
</div>
</template>
<script lang="ts">
export default defineComponent({
setup() {
const count = ref(0)
return { count }
}
})
</script>
落とし穴2:シリアライズ不可能なデータをサーバーからクライアントコンポーネントに渡す
❌ 間違った書き方:
<!-- components/BrokenPass.server.vue -->
<template>
<InteractiveChart :renderer="chartRenderer" :data="rawBuffer" />
</template>
<script lang="ts">
import { createChartRenderer } from 'heavy-chart-lib'
import { readFileSync } from 'node:fs'
export default defineComponent({
async setup() {
const chartRenderer = createChartRenderer() // 関数、シリアライズ不可
const rawBuffer = readFileSync('./data.bin') // Buffer、シリアライズ不可
return { chartRenderer, rawBuffer }
}
})
</script>
✅ 正しい書き方:
<!-- components/FixedPass.server.vue -->
<template>
<InteractiveChart :chart-config="chartConfig" :data-json="dataJson" />
</template>
<script lang="ts">
import { readFileSync } from 'node:fs'
export default defineComponent({
async setup() {
const rawData = JSON.parse(readFileSync('./data.json', 'utf-8'))
const chartConfig = { type: 'bar', animated: true }
const dataJson = JSON.stringify(rawData)
return { chartConfig, dataJson }
}
})
</script>
落とし穴3:Islandsパターンではなくフルハイドレーション
❌ 間違った書き方:
<!-- pages/index.vue - フルハイドレーション -->
<template>
<div>
<Header />
<HeroSection />
<ProductGrid />
<Footer />
</div>
</template>
✅ 正しい書き方:
<!-- pages/index.vue - Islandsパターン -->
<template>
<div>
<Header.server />
<HeroSection.server />
<ProductGrid.server />
<ShoppingCart.client />
<Footer.server />
</div>
</template>
落とし穴4:SuspenseにFallbackがない
❌ 間違った書き方:
<template>
<Suspense>
<SlowDataComponent.server />
</Suspense>
</template>
✅ 正しい書き方:
<template>
<Suspense>
<template #default>
<SlowDataComponent.server />
</template>
<template #fallback>
<LoadingSkeleton />
</template>
</Suspense>
</template>
落とし穴5:サーバーコンポーネントで環境変数を漏洩する
❌ 間違った書き方:
<!-- components/BrokenEnv.server.vue -->
<template>
<div>API Key: {{ apiKey }}</div>
</template>
<script lang="ts">
export default defineComponent({
setup() {
const apiKey = process.env.SECRET_API_KEY
return { apiKey } // HTMLにシリアライズされる!
}
})
</script>
✅ 正しい書き方:
<!-- components/SecureEnv.server.vue -->
<template>
<div>サービス状態:{{ status }}</div>
</template>
<script lang="ts">
export default defineComponent({
async setup() {
const apiKey = process.env.SECRET_API_KEY
const response = await fetch(`https://api.example.com/health`, {
headers: { Authorization: `Bearer ${apiKey}` }
})
const status = response.ok ? '正常' : '異常'
return { status } // 処理済みの結果のみ返す
}
})
</script>
エラートラブルシューティング早見表
| エラーメッセージ | 原因 | 解決策 |
|---|---|---|
Hydration mismatch |
サーバーレンダリングHTMLとクライアントハイドレーション結果が一致しない | サーバー/クライアントのデータ整合性を確認、ClientOnlyでラップ |
Cannot read property of null |
サーバーコンポーネントの非同期データが準備できていない | Suspense fallbackまたはv-ifガードを追加 |
Module not found: 'node:fs' |
クライアントコンポーネントがNode.jsモジュールを参照している | .server.vueに変更するか、ロジックをAPIルートに移動 |
props serialization failed |
シリアライズ不可能なpropsを渡している(関数、Bufferなど) | JSONシリアライズ可能なデータのみ渡す、関数はクライアントコンポーネントに移動 |
Maximum call stack exceeded |
サーバー/クライアントコンポーネントの循環参照 | コンポーネントのネスト関係を確認、.server.vue→.client.vue→.server.vueを避ける |
useFetch returned null |
SSR中のAPIリクエストが失敗 | APIルートを確認、default値を追加、サーバーのネットワーク到達性を確認 |
Text content did not match |
サーバーレンダリングの日付/ランダム値がクライアントと異なる | 動的コンテンツをClientOnlyでラップ、またはランダムシードを固定 |
500 Internal Server Error |
サーバーコンポーネントのsetupが例外をスロー | try-catchを追加、データベース接続とファイルパスを確認 |
Component is not rendered |
.client.vueがSSR時に空ノードを出力 |
これは正常な動作、クライアントハイドレーション後に正しく表示されることを確認 |
Performance warning: large props |
サーバーコンポーネントが大量のデータをクライアントコンポーネントに渡している | propsを必要なフィールドのみに削減、大きなデータはAPIでオンデマンドフェッチ |
💡 ハッシュ計算ツールを使用してサーバーコンポーネントのレンダリング結果のチェックサムを生成し、ハイドレーションの不一致を素早く特定。
高度な最適化テクニック
1. コンポーネントレベルのキャッシュ戦略
計算集約的だがデータ更新頻度が低いサーバーコンポーネントにキャッシュを実装し、リクエストごとの再レンダリングを回避する。
import { defineEventHandler, getHeader, setHeader } from 'h3'
import { renderToString } from 'vue/server-renderer'
import { createSSRApp } from 'vue'
import { LRUCache } from 'lru-cache'
const componentCache = new LRUCache<string, string>({
max: 500,
ttl: 1000 * 60 * 5
})
export async function renderCachedComponent(
component: any,
props: Record<string, any>,
cacheKey: string
): Promise<string> {
const cached = componentCache.get(cacheKey)
if (cached) return cached
const app = createSSRApp(component, props)
const html = await renderToString(app)
componentCache.set(cacheKey, html)
return html
}
2. 選択的ハイドレーションの優先度制御
ユーザーのインタラクション意図に基づいてハイドレーション順序を動的に調整し、ユーザーが操作しようとしているコンポーネントを優先的にハイドレーションする。
<!-- composables/useSelectiveHydration.ts -->
<script lang="ts">
interface HydrationPriority {
componentId: string
priority: number
element: HTMLElement | null
}
export function useSelectiveHydration() {
const hydrationQueue = ref<HydrationPriority[]>([])
const hydratedSet = ref<Set<string>>(new Set())
const scheduleHydration = (
componentId: string,
priority: number,
element: HTMLElement | null
) => {
if (hydratedSet.value.has(componentId)) return
hydrationQueue.value.push({ componentId, priority, element })
hydrationQueue.value.sort((a, b) => b.priority - a.priority)
requestIdleCallback(() => processQueue())
}
const processQueue = () => {
while (hydrationQueue.value.length > 0) {
const item = hydrationQueue.value.shift()!
if (item.element && !hydratedSet.value.has(item.componentId)) {
hydratedSet.value.add(item.componentId)
item.element.dataset.hydrated = 'true'
}
}
}
const onInteraction = (componentId: string) => {
const item = hydrationQueue.value.find(i => i.componentId === componentId)
if (item) item.priority = 100
processQueue()
}
return { scheduleHydration, onInteraction, hydratedSet }
}
</script>
3. ストリーミング中のプログレッシブエンハンスメント
Streaming SSR中に、まだハイドレーションされていないコンポーネントに基本的なHTMLインタラクション能力を提供し、ハイドレーション完了前でも基本的な操作ができるようにする。
<!-- components/ProgressiveButton.client.vue -->
<template>
<button
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
:class="{ 'animate-pulse': isHydrating }"
:disabled="isHydrating"
@click="handleClick"
>
<slot>クリック</slot>
</button>
</template>
<script lang="ts">
export default defineComponent({
emits: ['click'],
setup(_, { emit }) {
const isHydrating = ref(true)
onMounted(() => {
nextTick(() => {
isHydrating.value = false
})
})
const handleClick = () => {
if (!isHydrating.value) {
emit('click')
}
}
return { isHydrating, handleClick }
}
})
</script>
フレームワーク比較
| 特徴 | Next.js RSC | Nuxt Server Components | Astro Islands | Qwik Resumability |
|---|---|---|---|---|
| コア理念 | React Server Components | Vue Server Components | デフォルトでゼロJS | レジューマビリティ |
| サーバーコンポーネント | "use server"ディレクティブ |
.server.vueファイル規約 |
デフォルト動作 | 全てサーバー側シリアライズ |
| クライアントコンポーネント | "use client"ディレクティブ |
.client.vueファイル規約 |
client:loadディレクティブ |
オンデマンドレジューム |
| ハイドレーション戦略 | フルハイドレーション(クライアントコンポーネント) | Islands選択的ハイドレーション | Islandsオンデマンドハイドレーション | ゼロハイドレーション(Resumability) |
| ストリーミングSSR | ✅ 組み込み | ✅ Suspense + Streaming | ✅ 組み込み | ✅ 組み込み |
| データフェッチ | Server Actions / fetch | 直接DB / useFetch | Astro.glob / fetch | server$関数 |
| バンドル最適化 | サーバー依存を除外 | サーバー依存を除外 | デフォルトでゼロJS | 極限レイジーロード |
| Vueエコシステム | ❌ Reactのみ | ✅ ネイティブ | ✅ Vue対応 | ❌ Qwikのみ |
| Reactエコシステム | ✅ ネイティブ | ❌ Vueのみ | ✅ React対応 | ❌ Qwikのみ |
| 学習曲線 | 中程度 | 低い(Vue開発者向け) | 低い | 高い |
| プロダクション対応 | ✅ 2023+ | ✅ 2025+ | ✅ 2023+ | ⚠️ エコシステム構築中 |
| 適用シーン | 複雑なReactアプリ | Vueフルスタックアプリ | コンテンツサイト / ブログ | 極限パフォーマンス要件 |
まとめ
Vue 3サーバーコンポーネントは銀の弾丸ではないが、Vueエコシステムにハイドレーション境界を正確に制御する能力を提供する。Islandsアーキテクチャによりインタラクションが必要な部分のみハイドレーションし、Streaming SSRにより遅いクエリによるページブロックを防ぎ、Nuxt 4の
.server.vue/.client.vue規約によりこれらすべてを直感的にする。コア原則を忘れずに:静的コンテンツはサーバーに残し、インタラクティブロジックはクライアントに送り、データはできるだけ近い場所でフェッチする。
おすすめツール
- JSONフォーマッター — Nuxt設定とAPIレスポンス構造の検証
- Base64エンコード — サーバーからクライアントに渡す初期状態データのエンコード
- ハッシュ計算 — サーバーレンダリング結果のチェックサム生成、ハイドレーション不一致のトラブルシューティング
ブラウザローカルツールを無料で試す →