Vue3 + Nuxt 4フルスタックAIアプリ:SSRからエッジ推論までの7つのプロダクションパターン
Vue3 + Nuxt 4フルスタックAIアプリ:SSRからエッジ推論までの7つのプロダクションパターン
Vue開発者がAIアプリを作る際、まだフロントエンドとバックエンドを分離し、CORS問題に対処し、2つのシステムをデプロイしていますか?Nuxt 4のServer Routes + SSR + Edge Runtimeにより、Vue3フルスタックAIアプリが現実になります——1つのコードベース、1回のデプロイ、SSRによるAIコンテンツ出力。本記事では、Server Routesプロキシからエッジ推論まで、7つのプロダクションレベルのパターンを深く掘り下げます。すべてのコード行がプロダクションで直接使用可能です。
主要な学び
- Nuxt 4 Server RoutesによるAI APIプロキシ構築の完全ソリューションを習得
- SSR + AIストリーミングレンダリングのファーストペイント最適化戦略を実装
- プロダクションレベルのストリーミングチャットUIコンポーネントを構築
- Cloudflare Workersへのエッジ推論デプロイ
- RAGフロントエンドインタラクション体験を設計
- Piniaによる会話状態の永続化とページをまたぐ復元
- プロダクション環境のパフォーマンス最適化とデプロイのベストプラクティス
目次
- Nuxt 4フルスタックAIアーキテクチャ概要
- パターン1: Server Routes + AI APIプロキシ
- パターン2: SSR + AIストリーミングレンダリング
- パターン3: ストリーミングチャットUIコンポーネント
- パターン4: エッジ推論とCloudflare Workers
- パターン5: RAGフロントエンドインタラクション設計
- パターン6: 会話状態とPinia永続化
- パターン7: プロダクションデプロイとパフォーマンス最適化
- 5つのよくある落とし穴と解決策
- 10のよくあるエラートラブルシューティング
- 高度な最適化テクニック
- 比較分析:Nuxt 4 vs Next.js 15 vs SvelteKit
- オンラインツールのおすすめ
- まとめ
Nuxt 4フルスタックAIアーキテクチャ概要
2026年のNuxt 4は、フルスタックAIアプリケーションに不可欠な機能をもたらします:Server RoutesによるバックエンドAPI層、SSRストリーミングレンダリング、Edge Runtimeサポート、そしてネイティブTypeScriptによるエンドツーエンドの型安全性です。
┌──────────────────────────────────────────────────────────┐
│ Nuxt 4 Full-Stack AI Architecture │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Browser │───▶│ Nuxt SSR │───▶│ Edge / │ │
│ │ Client │◀───│ Server │◀───│ Node │ │
│ └──────┬──────┘ └──────┬───────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌──────▼───────┐ ┌─────▼──────┐ │
│ │ Vue3 │ │ Server │ │ AI Models │ │
│ │ Composables│ │ Routes │ │ OpenAI │ │
│ │ Pinia │ │ /api/chat │ │ Anthropic │ │
│ │ Components │ │ /api/embed │ │ Local LLM │ │
│ └─────────────┘ │ /api/rag │ └────────────┘ │
│ └──────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Shared Layer: Types / Utils / Constants │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Nuxt 4フルスタックAIコア機能
| 機能 | 説明 | ユースケース |
|---|---|---|
| Server Routes | ファイルベースAPIルーティング、Express不要 | AIプロキシ、Webhook、BFF |
| SSRストリーミング | サーバー側ストリーミングHTML出力 | SEO + AIファーストペイント |
| Edge Runtime | Cloudflare/Denoエッジデプロイ | 低レイテンシ推論 |
| Nitro Engine | クロスプラットフォームサーバーエンジン | マルチ環境統一デプロイ |
| Shared Types | フロントエンド・バックエンド型共有 | エンドツーエンド型安全性 |
| useAsyncData | SSRデータフェッチ | AIデータプリロード |
パターン1: Server Routes + AI APIプロキシ
Nuxt 4のServer Routesにより、独立したバックエンドを構築することなく、Nuxtプロジェクト内に直接APIを記述できます。これはフルスタックAIの基盤です——すべてのAIリクエストがServer Routesを経由してプロキシされ、APIキーの露出を防ぎ、エラーハンドリングを統一し、レート制限を実装します。
基本的なServer Routeプロキシ
// server/api/chat.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
import { z } from 'zod'
const chatRequestSchema = z.object({
messages: z.array(z.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string().max(4000),
})).min(1).max(50),
model: z.enum(['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4-20250514']).default('gpt-4o-mini'),
temperature: z.number().min(0).max(2).default(0.7),
maxTokens: z.number().min(1).max(4096).default(2048),
})
const RATE_LIMIT_WINDOW = 60_000
const RATE_LIMIT_MAX = 20
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const record = requestCounts.get(ip)
if (!record || now > record.resetAt) {
requestCounts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW })
return true
}
if (record.count >= RATE_LIMIT_MAX) {
return false
}
record.count++
return true
}
export default defineEventHandler(async (event) => {
const clientIp = getRequestHeader(event, 'x-forwarded-for') || 'unknown'
if (!checkRateLimit(clientIp)) {
throw createError({
statusCode: 429,
statusMessage: 'Rate limit exceeded. Please try again later.',
})
}
const body = await readBody(event)
const parsed = chatRequestSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 400,
statusMessage: `Validation error: ${parsed.error.message}`,
})
}
const { messages, model, temperature, maxTokens } = parsed.data
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) {
throw createError({
statusCode: 500,
statusMessage: 'AI service not configured',
})
}
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages,
temperature,
max_tokens: maxTokens,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw createError({
statusCode: response.status,
statusMessage: errorData.error?.message || 'AI service error',
})
}
return response.json()
})
ストリーミングServer Route
// server/api/chat/stream.post.ts
import { defineEventHandler, readBody, createError, setResponseHeader, sendStream } from 'h3'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { messages, model = 'gpt-4o-mini' } = body
setResponseHeader(event, 'Content-Type', 'text/event-stream')
setResponseHeader(event, 'Cache-Control', 'no-cache')
setResponseHeader(event, 'Connection', 'keep-alive')
setResponseHeader(event, 'X-Accel-Buffering', 'no')
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) {
throw createError({ statusCode: 500, statusMessage: 'AI service not configured' })
}
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages,
stream: true,
}),
})
if (!response.ok) {
throw createError({
statusCode: response.status,
statusMessage: 'AI streaming error',
})
}
const transformStream = new TransformStream({
transform(chunk, controller) {
const text = new TextDecoder().decode(chunk)
const lines = text.split('\n').filter((line) => line.startsWith('data: '))
for (const line of lines) {
const data = line.slice(6)
if (data === '[DONE]') {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
continue
}
try {
const parsed = JSON.parse(data)
const content = parsed.choices?.[0]?.delta?.content
if (content) {
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ content })}\n\n`))
}
} catch {
// skip malformed chunks
}
}
},
})
const readableStream = response.body!.pipeThrough(transformStream)
return sendStream(event, readableStream)
})
共有型定義
// shared/types/ai.ts
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
metadata?: MessageMetadata
}
export interface MessageMetadata {
model: string
tokens: number
latency: number
finishReason: string
}
export interface ChatRequest {
messages: Pick<ChatMessage, 'role' | 'content'>[]
model: AIModel
temperature?: number
maxTokens?: number
stream?: boolean
}
export type AIModel = 'gpt-4o' | 'gpt-4o-mini' | 'claude-sonnet-4-20250514'
export interface StreamChunk {
content: string
done: boolean
}
export interface RAGQuery {
question: string
topK?: number
threshold?: number
}
export interface RAGResult {
answer: string
sources: RAGSource[]
confidence: number
}
export interface RAGSource {
content: string
metadata: Record<string, unknown>
score: number
}
パターン2: SSR + AIストリーミングレンダリング
SSRとAIを組み合わせる核心的な価値:検索エンジンがAI生成コンテンツをインデックスでき、ユーザーはファーストペイントでAI回答を確認できます。Nuxt 4のuseAsyncData + Server Routesにより、SSR AIレンダリングがシンプルになります。
SSRデータプリロード
// composables/useAIContent.ts
import { useAsyncData, useHead } from '#imports'
interface AIContentOptions {
prompt: string
model?: AIModel
ttl?: number
}
export function useAIContent(options: AIContentOptions) {
const { prompt, model = 'gpt-4o-mini', ttl = 3600 } = options
const { data, pending, error, refresh } = useAsyncData(
`ai-content-${prompt.slice(0, 32)}`,
() => $fetch<string>('/api/ai/generate', {
method: 'POST',
body: { prompt, model },
}),
{
server: true,
lazy: false,
getCachedData(key, nuxtApp) {
const cached = nuxtApp.payload.data[key]
if (cached) {
const expirationDate = new Date(cached.expiresAt)
if (expirationDate.getTime() > Date.now()) {
return cached.data
}
}
return null
},
}
)
useHead({
meta: [
{ name: 'description', content: () => data.value?.slice(0, 160) || '' },
],
})
return { content: data, pending, error, refresh }
}
SSR AIページコンポーネント
<!-- pages/ai-insights/[topic].vue -->
<script setup lang="ts">
const route = useRoute()
const topic = route.params.topic as string
const { content, pending, error } = useAIContent({
prompt: `Generate a comprehensive technical insight about ${topic} for developers in 2026`,
model: 'gpt-4o-mini',
})
useHead({
title: () => `AI Insights: ${topic} | ToolsKu`,
})
</script>
<template>
<div class="mx-auto max-w-4xl px-4 py-8">
<header class="mb-8">
<h2 class="text-3xl font-bold text-gray-900">
AI Insights: {{ topic }}
</h2>
<p class="mt-2 text-gray-500">
AI-generated analysis, verified and curated for developers
</p>
</header>
<div v-if="pending" class="space-y-4">
<div class="h-8 w-3/4 animate-pulse rounded bg-gray-200" />
<div class="h-8 w-1/2 animate-pulse rounded bg-gray-200" />
<div class="h-8 w-2/3 animate-pulse rounded bg-gray-200" />
</div>
<div v-else-if="error" class="rounded-lg bg-red-50 p-4">
<p class="text-red-700">Failed to generate AI content. Please try again.</p>
</div>
<article v-else class="prose prose-lg max-w-none">
<div v-html="content" />
</article>
</div>
</template>
SSRキャッシュミドルウェア
// server/middleware/ai-cache.ts
import { defineEventHandler, setResponseHeader } from 'h3'
import { useStorage } from '#imports'
const aiCache = useStorage('ai-cache')
export default defineEventHandler(async (event) => {
if (!event.path.startsWith('/api/ai/')) return
const cacheKey = `ssr-ai:${event.path}:${JSON.stringify(await readBody(event).catch(() => ({})))}`
const cached = await aiCache.getItem<{ data: string; expiresAt: number }>(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
setResponseHeader(event, 'X-AI-Cache', 'HIT')
return cached.data
}
setResponseHeader(event, 'X-AI-Cache', 'MISS')
})
パターン3: ストリーミングチャットUIコンポーネント
ストリーミングチャットはAIアプリケーションの中核的なインタラクションです。このパターンでは、Markdownレンダリング、コードハイライト、生成の中断、メッセージリトライをサポートする、完全なプロダクションレベルのストリーミングチャットコンポーネントを実装します。
ストリーミングチャットComposable
// composables/useStreamingChat.ts
import { ref, computed } from 'vue'
import type { ChatMessage, StreamChunk, AIModel } from '~/shared/types/ai'
interface UseStreamingChatOptions {
apiEndpoint?: string
defaultModel?: AIModel
maxRetries?: number
}
export function useStreamingChat(options: UseStreamingChatOptions = {}) {
const {
apiEndpoint = '/api/chat/stream',
defaultModel = 'gpt-4o-mini',
maxRetries = 2,
} = options
const messages = ref<ChatMessage[]>([])
const currentStreamContent = ref('')
const isStreaming = ref(false)
const error = ref<string | null>(null)
const selectedModel = ref<AIModel>(defaultModel)
let abortController: AbortController | null = null
const displayedMessages = computed(() => {
const base = [...messages.value]
if (isStreaming.value && currentStreamContent.value) {
base.push({
id: 'streaming',
role: 'assistant',
content: currentStreamContent.value,
timestamp: Date.now(),
})
}
return base
})
async function sendMessage(content: string) {
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: Date.now(),
}
messages.value.push(userMessage)
error.value = null
currentStreamContent.value = ''
isStreaming.value = true
abortController = new AbortController()
let retryCount = 0
const attemptStream = async (): Promise<void> => {
try {
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: messages.value.map((m) => ({
role: m.role,
content: m.content,
})),
model: selectedModel.value,
}),
signal: abortController!.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') {
finalizeStream()
return
}
try {
const chunk: StreamChunk = JSON.parse(data)
currentStreamContent.value += chunk.content
} catch {
// skip malformed chunks
}
}
}
finalizeStream()
} catch (err: any) {
if (err.name === 'AbortError') return
if (retryCount < maxRetries) {
retryCount++
return attemptStream()
}
error.value = err.message
isStreaming.value = false
}
}
await attemptStream()
}
function finalizeStream() {
if (currentStreamContent.value) {
messages.value.push({
id: crypto.randomUUID(),
role: 'assistant',
content: currentStreamContent.value,
timestamp: Date.now(),
})
}
currentStreamContent.value = ''
isStreaming.value = false
}
function stopStreaming() {
abortController?.abort()
finalizeStream()
}
function retryLastMessage() {
const lastUserIndex = messages.value.findLastIndex((m) => m.role === 'user')
if (lastUserIndex === -1) return
const lastUserContent = messages.value[lastUserIndex].content
messages.value = messages.value.slice(0, lastUserIndex)
sendMessage(lastUserContent)
}
function clearMessages() {
messages.value = []
currentStreamContent.value = ''
error.value = null
}
return {
messages: displayedMessages,
isStreaming,
error,
selectedModel,
sendMessage,
stopStreaming,
retryLastMessage,
clearMessages,
}
}
チャットUIコンポーネント
<!-- components/AIChatWindow.vue -->
<script setup lang="ts">
import { useStreamingChat } from '~/composables/useStreamingChat'
import { useConversationStore } from '~/stores/conversation'
const props = defineProps<{
conversationId?: string
}>()
const {
messages,
isStreaming,
error,
selectedModel,
sendMessage,
stopStreaming,
retryLastMessage,
clearMessages,
} = useStreamingChat()
const conversationStore = useConversationStore()
const inputText = ref('')
const messagesContainer = ref<HTMLElement>()
const modelOptions = [
{ label: 'GPT-4o', value: 'gpt-4o' as const },
{ label: 'GPT-4o Mini', value: 'gpt-4o-mini' as const },
{ label: 'Claude Sonnet 4', value: 'claude-sonnet-4-20250514' as const },
]
async function handleSubmit() {
const text = inputText.value.trim()
if (!text || isStreaming.value) return
inputText.value = ''
await sendMessage(text)
if (props.conversationId) {
conversationStore.saveConversation(props.conversationId, messages.value)
}
scrollToBottom()
}
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
watch(messages, () => scrollToBottom(), { deep: true })
</script>
<template>
<div class="flex h-full flex-col rounded-xl border border-gray-200 bg-white shadow-sm">
<header class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700">AI Chat</span>
<select
v-model="selectedModel"
class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600"
:disabled="isStreaming"
>
<option v-for="opt in modelOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="flex gap-2">
<button
class="rounded-md px-2 py-1 text-xs text-gray-500 hover:bg-gray-100"
@click="retryLastMessage"
:disabled="isStreaming || messages.length === 0"
>
Retry
</button>
<button
class="rounded-md px-2 py-1 text-xs text-red-500 hover:bg-red-50"
@click="clearMessages"
:disabled="isStreaming"
>
Clear
</button>
</div>
</header>
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-4">
<div
v-for="msg in messages"
:key="msg.id"
:class="[
'max-w-[80%] rounded-lg px-4 py-2.5 text-sm',
msg.role === 'user'
? 'ml-auto bg-blue-600 text-white'
: 'mr-auto bg-gray-100 text-gray-900',
]"
>
<div v-if="msg.role === 'assistant'" class="prose prose-sm max-w-none" v-html="renderMarkdown(msg.content)" />
<p v-else>{{ msg.content }}</p>
</div>
<div v-if="error" class="mx-auto max-w-md rounded-lg bg-red-50 p-3 text-center text-sm text-red-600">
{{ error }}
<button class="ml-2 underline" @click="retryLastMessage">Retry</button>
</div>
</div>
<footer class="border-t border-gray-200 p-3">
<div class="flex gap-2">
<input
v-model="inputText"
type="text"
placeholder="メッセージを入力..."
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
@keydown.enter="handleSubmit"
:disabled="isStreaming"
/>
<button
v-if="isStreaming"
class="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
@click="stopStreaming"
>
Stop
</button>
<button
v-else
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
@click="handleSubmit"
:disabled="!inputText.trim()"
>
Send
</button>
</div>
</footer>
</div>
</template>
パターン4: エッジ推論とCloudflare Workers
エッジ推論は2026年のAIアプリケーションにおける重要トレンドです——推論ロジックをユーザーに最も近いエッジノードにデプロイすることで、レイテンシを数百ミリ秒から一桁に削減します。Nuxt 4 + Nitroにより、Cloudflare Workersへのデプロイが極めてシンプルになります。
Nitro Edge設定
// nuxt.config.ts
export default defineNuxtConfig({
future: {
compatibilityVersion: 4,
},
nitro: {
preset: 'cloudflare-module',
runtimeConfig: {
aiApiKey: process.env.OPENAI_API_KEY,
aiBaseUrl: process.env.AI_BASE_URL || 'https://api.openai.com/v1',
},
routeRules: {
'/api/ai/**': {
cors: true,
headers: {
'cache-control': 'no-cache',
},
},
},
},
})
エッジ推論Server Route
// server/api/ai/edge-chat.post.ts
import { defineEventHandler, readBody, setResponseHeader, sendStream } from 'h3'
interface EdgeAIConfig {
provider: 'openai' | 'anthropic' | 'local'
baseUrl: string
apiKey: string
}
function getAIConfig(event: any): EdgeAIConfig {
const config = useRuntimeConfig(event)
const provider = getHeader(event, 'x-ai-provider') || 'openai'
const configs: Record<string, EdgeAIConfig> = {
openai: {
provider: 'openai',
baseUrl: config.public.aiBaseUrl || 'https://api.openai.com/v1',
apiKey: config.aiApiKey,
},
anthropic: {
provider: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
apiKey: process.env.ANTHROPIC_API_KEY || '',
},
local: {
provider: 'local',
baseUrl: process.env.LOCAL_AI_URL || 'http://localhost:11434/v1',
apiKey: 'local',
},
}
return configs[provider] || configs.openai
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { messages, model = 'gpt-4o-mini' } = body
const aiConfig = getAIConfig(event)
setResponseHeader(event, 'Content-Type', 'text/event-stream')
setResponseHeader(event, 'Cache-Control', 'no-cache')
setResponseHeader(event, 'Connection', 'keep-alive')
const endpoint = aiConfig.provider === 'anthropic'
? `${aiConfig.baseUrl}/messages`
: `${aiConfig.baseUrl}/chat/completions`
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
}
if (aiConfig.provider === 'anthropic') {
requestHeaders['x-api-key'] = aiConfig.apiKey
requestHeaders['anthropic-version'] = '2023-06-01'
} else {
requestHeaders['Authorization'] = `Bearer ${aiConfig.apiKey}`
}
const requestBody = aiConfig.provider === 'anthropic'
? {
model,
messages: messages.map((m: any) => ({ role: m.role, content: m.content })),
max_tokens: 2048,
stream: true,
}
: {
model,
messages: messages.map((m: any) => ({ role: m.role, content: m.content })),
stream: true,
}
const response = await fetch(endpoint, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw createError({
statusCode: response.status,
statusMessage: `Edge AI error: ${response.statusText}`,
})
}
return sendStream(event, response.body!)
})
Wranglerデプロイ設定
# wrangler.toml
name = "toolsku-ai-edge"
main = ".output/server/index.mjs"
compatibility_date = "2026-06-01"
compatibility_flags = ["nodejs_compat"]
[vars]
AI_BASE_URL = "https://api.openai.com/v1"
[ai]
binding = "AI"
[[r2_buckets]]
binding = "AI_CACHE"
bucket_name = "toolsku-ai-cache"
[observability]
enabled = true
パターン5: RAGフロントエンドインタラクション設計
RAG(検索拡張生成)はAIアプリケーションで最も実用的なパターンの1つです。フロントエンドは、ドキュメントアップロード、ベクトル検索、結果表示の完全なインタラクションチェーンを処理する必要があります。
RAGクエリComposable
// composables/useRAG.ts
import { ref, computed } from 'vue'
import type { RAGQuery, RAGResult, RAGSource } from '~/shared/types/ai'
interface DocumentChunk {
id: string
content: string
metadata: {
source: string
page: number
section: string
}
score: number
}
export function useRAG() {
const query = ref('')
const isSearching = ref(false)
const isGenerating = ref(false)
const searchResults = ref<DocumentChunk[]>([])
const ragAnswer = ref('')
const ragSources = ref<RAGSource[]>([])
const error = ref<string | null>(null)
const hasResults = computed(() => searchResults.value.length > 0)
const isProcessing = computed(() => isSearching.value || isGenerating.value)
async function searchDocuments(searchQuery: string, topK = 5) {
isSearching.value = true
error.value = null
searchResults.value = []
try {
const results = await $fetch<DocumentChunk[]>('/api/rag/search', {
method: 'POST',
body: { query: searchQuery, topK },
})
searchResults.value = results
} catch (err: any) {
error.value = err.data?.message || 'Search failed'
} finally {
isSearching.value = false
}
}
async function generateAnswer(searchQuery: string) {
isGenerating.value = true
ragAnswer.value = ''
ragSources.value = []
try {
const result = await $fetch<RAGResult>('/api/rag/generate', {
method: 'POST',
body: {
question: searchQuery,
topK: 5,
threshold: 0.7,
} satisfies RAGQuery,
})
ragAnswer.value = result.answer
ragSources.value = result.sources
} catch (err: any) {
error.value = err.data?.message || 'Generation failed'
} finally {
isGenerating.value = false
}
}
async function fullRAGPipeline(searchQuery: string) {
await searchDocuments(searchQuery)
if (searchResults.value.length > 0) {
await generateAnswer(searchQuery)
}
}
return {
query,
isSearching,
isGenerating,
isProcessing,
searchResults,
ragAnswer,
ragSources,
hasResults,
error,
searchDocuments,
generateAnswer,
fullRAGPipeline,
}
}
RAGインタラクションコンポーネント
<!-- components/RAGSearchPanel.vue -->
<script setup lang="ts">
import { useRAG } from '~/composables/useRAG'
const {
query,
isProcessing,
searchResults,
ragAnswer,
ragSources,
error,
fullRAGPipeline,
} = useRAG()
const showSources = ref(false)
async function handleSearch() {
if (!query.value.trim()) return
await fullRAGPipeline(query.value)
}
</script>
<template>
<div class="mx-auto max-w-3xl space-y-6">
<div class="flex gap-2">
<input
v-model="query"
type="text"
placeholder="ドキュメントについて質問..."
class="flex-1 rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
@keydown.enter="handleSearch"
:disabled="isProcessing"
/>
<button
class="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@click="handleSearch"
:disabled="isProcessing || !query.trim()"
>
{{ isProcessing ? '検索中...' : '検索' }}
</button>
</div>
<div v-if="error" class="rounded-lg bg-red-50 p-4 text-sm text-red-600">
{{ error }}
</div>
<div v-if="ragAnswer" class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h3 class="mb-3 text-sm font-semibold text-gray-500 uppercase tracking-wide">AI 回答</h3>
<div class="prose prose-sm max-w-none" v-html="ragAnswer" />
<button
class="mt-4 text-xs text-blue-600 hover:underline"
@click="showSources = !showSources"
>
{{ showSources ? '非表示' : '表示' }} ソース ({{ ragSources.length }})
</button>
</div>
<div v-if="showSources && ragSources.length" class="space-y-3">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">ソース</h3>
<div
v-for="(source, index) in ragSources"
:key="index"
class="rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500">
関連度: {{ (source.score * 100).toFixed(1) }}%
</span>
</div>
<p class="text-sm text-gray-700 line-clamp-3">{{ source.content }}</p>
</div>
</div>
<div v-if="searchResults.length && !ragAnswer" class="space-y-3">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">一致チャンク</h3>
<div
v-for="chunk in searchResults"
:key="chunk.id"
class="rounded-lg border border-gray-200 bg-white p-4"
>
<div class="mb-2 flex items-center gap-2">
<span class="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{{ chunk.metadata.source }}
</span>
<span class="text-xs text-gray-400">{{ chunk.metadata.page }} ページ</span>
</div>
<p class="text-sm text-gray-700">{{ chunk.content }}</p>
</div>
</div>
</div>
</template>
パターン6: 会話状態とPinia永続化
AIチャットアプリケーションの核心的な課題の1つは状態管理です——会話履歴、モデル選択、ユーザー設定すべてを永続化する必要があります。Pinia + Nuxt 4のSSR互換ソリューションにより、これがシンプルになります。
会話Store
// stores/conversation.ts
import { defineStore } from 'pinia'
import type { ChatMessage, AIModel } from '~/shared/types/ai'
interface Conversation {
id: string
title: string
messages: ChatMessage[]
model: AIModel
createdAt: number
updatedAt: number
}
interface ConversationState {
conversations: Map<string, Conversation>
activeConversationId: string | null
preferences: {
defaultModel: AIModel
temperature: number
systemPrompt: string
streamByDefault: boolean
}
}
export const useConversationStore = defineStore('conversation', {
state: (): ConversationState => ({
conversations: new Map(),
activeConversationId: null,
preferences: {
defaultModel: 'gpt-4o-mini',
temperature: 0.7,
systemPrompt: 'You are a helpful assistant.',
streamByDefault: true,
},
}),
getters: {
activeConversation(state): Conversation | undefined {
if (!state.activeConversationId) return undefined
return state.conversations.get(state.activeConversationId)
},
conversationList(state): Conversation[] {
return Array.from(state.conversations.values())
.sort((a, b) => b.updatedAt - a.updatedAt)
},
messageCount(state): number {
return (id: string) => state.conversations.get(id)?.messages.length || 0
},
},
actions: {
createConversation(title?: string): string {
const id = crypto.randomUUID()
const conversation: Conversation = {
id,
title: title || `Chat ${this.conversations.size + 1}`,
messages: [],
model: this.preferences.defaultModel,
createdAt: Date.now(),
updatedAt: Date.now(),
}
this.conversations.set(id, conversation)
this.activeConversationId = id
this.persist()
return id
},
saveConversation(id: string, messages: ChatMessage[]) {
const conversation = this.conversations.get(id)
if (!conversation) return
conversation.messages = messages
conversation.updatedAt = Date.now()
if (messages.length > 0 && messages[0].role === 'user') {
conversation.title = messages[0].content.slice(0, 50)
}
this.persist()
},
deleteConversation(id: string) {
this.conversations.delete(id)
if (this.activeConversationId === id) {
const remaining = this.conversationList
this.activeConversationId = remaining.length > 0 ? remaining[0].id : null
}
this.persist()
},
setActiveConversation(id: string) {
this.activeConversationId = id
},
updatePreferences(prefs: Partial<ConversationState['preferences']>) {
this.preferences = { ...this.preferences, ...prefs }
this.persist()
},
persist() {
if (import.meta.client) {
const data = {
conversations: Object.fromEntries(this.conversations),
activeConversationId: this.activeConversationId,
preferences: this.preferences,
}
localStorage.setItem('toolsku-ai-conversations', JSON.stringify(data))
}
},
hydrate() {
if (import.meta.client) {
const stored = localStorage.getItem('toolsku-ai-conversations')
if (stored) {
try {
const data = JSON.parse(stored)
this.conversations = new Map(Object.entries(data.conversations))
this.activeConversationId = data.activeConversationId
this.preferences = data.preferences
} catch {
localStorage.removeItem('toolsku-ai-conversations')
}
}
}
},
},
})
SSR安全初期化プラグイン
// plugins/conversation-init.client.ts
import { useConversationStore } from '~/stores/conversation'
export default defineNuxtPlugin(() => {
const store = useConversationStore()
store.hydrate()
})
パターン7: プロダクションデプロイとパフォーマンス最適化
開発からプロダクションまで、Nuxt 4フルスタックAIアプリケーションはパフォーマンス、セキュリティ、オブザーバビリティに注力する必要があります。
プロダクション設定
// nuxt.config.ts (production)
export default defineNuxtConfig({
future: {
compatibilityVersion: 4,
},
nitro: {
compressPublicAssets: true,
minify: true,
routeRules: {
'/api/ai/**': {
cors: false,
headers: {
'strict-transport-security': 'max-age=31536000; includeSubDomains',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
},
},
'/api/chat/stream': {
headers: {
'cache-control': 'no-cache, no-store, must-revalidate',
'x-accel-buffering': 'no',
},
},
},
rollupConfig: {
external: ['sharp', 'canvas'],
},
},
app: {
head: {
meta: [
{ 'http-equiv': 'X-UA-Compatible', content: 'IE=edge' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
},
},
experimental: {
payloadExtraction: true,
renderJsonPayloads: true,
},
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
'ai-vendor': ['openai'],
'markdown': ['marked', 'highlight.js'],
},
},
},
},
},
})
パフォーマンス監視Composable
// composables/useAIPerformance.ts
import { ref, computed } from 'vue'
interface PerformanceMetric {
name: string
startTime: number
endTime: number
duration: number
metadata?: Record<string, unknown>
}
export function useAIPerformance() {
const metrics = ref<PerformanceMetric[]>([])
const activeTimers = new Map<string, number>()
function startTimer(name: string) {
activeTimers.set(name, performance.now())
}
function endTimer(name: string, metadata?: Record<string, unknown>) {
const startTime = activeTimers.get(name)
if (startTime === undefined) return
const endTime = performance.now()
metrics.value.push({
name,
startTime,
endTime,
duration: endTime - startTime,
metadata,
})
activeTimers.delete(name)
}
const averageLatency = computed(() => {
const chatMetrics = metrics.value.filter((m) => m.name === 'ai-response')
if (chatMetrics.length === 0) return 0
return chatMetrics.reduce((sum, m) => sum + m.duration, 0) / chatMetrics.length
})
const p95Latency = computed(() => {
const chatMetrics = metrics.value
.filter((m) => m.name === 'ai-response')
.sort((a, b) => a.duration - b.duration)
if (chatMetrics.length < 2) return 0
const index = Math.ceil(chatMetrics.length * 0.95) - 1
return chatMetrics[index].duration
})
function getReport() {
return {
totalRequests: metrics.value.filter((m) => m.name === 'ai-response').length,
averageLatency: averageLatency.value,
p95Latency: p95Latency.value,
errorRate: metrics.value.filter((m) => m.metadata?.error).length / Math.max(metrics.value.length, 1),
}
}
return { metrics, startTimer, endTimer, averageLatency, p95Latency, getReport }
}
ヘルスチェックエンドポイント
// server/api/health.get.ts
import { defineEventHandler } from 'h3'
export default defineEventHandler(async () => {
const checks: Record<string, { status: 'ok' | 'error'; latency?: number; error?: string }> = {}
const aiStart = Date.now()
try {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('API key not configured')
await fetch('https://api.openai.com/v1/models', {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(5000),
})
checks.ai = { status: 'ok', latency: Date.now() - aiStart }
} catch (err: any) {
checks.ai = { status: 'error', error: err.message }
}
const overallStatus = Object.values(checks).every((c) => c.status === 'ok') ? 'ok' : 'degraded'
return {
status: overallStatus,
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || 'unknown',
checks,
}
})
5つのよくある落とし穴と解決策
落とし穴1: SSR時のlocalStorageアクセスによるハイドレーションミスマッチ
問題: Pinia永続化がSSR段階でlocalStorageを読み取り、クライアントハイドレーション時にデータが不一致になる。
解決策:
// クライアントでのみ永続化ロジックを実行
if (import.meta.client) {
store.hydrate()
}
// またはuseCookieをlocalStorageの代わりに使用
const savedData = useCookie('ai-conversations', {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'lax',
})
落とし穴2: ストリーミングレスポンスがNginx/CDNでバッファリングされる
問題: SSEストリーミングレスポンスが中間層でバッファリングされ、ユーザーが文字単位の出力を見られない。
解決策:
# nginx.conf
location /api/chat/stream {
proxy_pass http://nuxt_backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
落とし穴3: Server RoutesでのAPIキー露出
問題: フロントエンドコードやエラーレスポンスでAI APIキーが漏洩する。
解決策:
// server/api/chat.post.ts
// レスポンスでAPIキーを返さない
const apiKey = useRuntimeConfig().aiApiKey // サーバー側プライベート設定
// エラーレスポンスも機密情報をフィルタリング
catch (err: any) {
throw createError({
statusCode: 500,
statusMessage: 'AI service error', // err.messageを露出しない
})
}
落とし穴4: エッジランタイムがNode.js APIをサポートしていない
問題: Cloudflare Workersはfs、pathなどのNode.jsモジュールをサポートしていない。
解決策:
// Nitroのauto-import検出を使用
// server/api/ai/edge-chat.post.ts
// Node.js APIを避け、Web標準APIを使用
// 誤り:import { readFile } from 'fs'
// 正しい:env変数またはKVストレージを使用
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
// ファイル読み取りではなくconfigを使用
})
落とし穴5: 大量の会話履歴によるトークン制限超過
問題: 完全な会話履歴をAI APIに送信すると、モデルのトークン制限を超える。
解決策:
// utils/message-trimmer.ts
import type { ChatMessage } from '~/shared/types/ai'
const MAX_CONTEXT_TOKENS = 4096
const CHARS_PER_TOKEN = 4
export function trimMessages(messages: ChatMessage[], maxTokens = MAX_CONTEXT_TOKENS): ChatMessage[] {
let totalTokens = 0
const trimmed: ChatMessage[] = []
for (let i = messages.length - 1; i >= 0; i--) {
const estimatedTokens = Math.ceil(messages[i].content.length / CHARS_PER_TOKEN)
if (totalTokens + estimatedTokens > maxTokens) break
totalTokens += estimatedTokens
trimmed.unshift(messages[i])
}
if (trimmed[0]?.role !== 'system' && messages[0]?.role === 'system') {
trimmed.unshift(messages[0])
}
return trimmed
}
10のよくあるエラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決策 |
|---|---|---|---|
| 1 | Hydration mismatch |
SSR/CSRデータの不一致 | localStorage操作がimport.meta.client内にあることを確認 |
| 2 | 429 Too Many Requests |
AI APIレート制限 | リクエストキューと指数バックオフリトライを実装 |
| 3 | fetch failed in Server Routes |
SSR段階で外部APIにアクセス不可 | サーバー側ネットワークとDNS設定を確認 |
| 4 | CORS error |
クロスオリジンリクエストがブロック | 直接呼び出しではなくServer Routesプロキシを使用 |
| 5 | Stream interrupted |
接続タイムアウトまたは中断 | 再開と自動再接続を実装 |
| 6 | context_length_exceeded |
会話履歴が長すぎる | trimMessagesでコンテキストをトリム |
| 7 | Invalid API Key |
環境変数が未設定 | .envとruntimeConfig設定を確認 |
| 8 | Worker exceeded CPU time limit |
エッジランタイムタイムアウト | 推論ロジックを最適化、ストリーミングレスポンスを使用 |
| 9 | Module not found: fs |
エッジ環境がNodeモジュールをサポートしない | Node.js APIの代わりにWeb標準APIを使用 |
| 10 | Pinia store not initialized |
SSR段階でStoreが準備未完了 | callOnceまたはonNuxtReadyで初期化 |
高度な最適化テクニック
1. AIレスポンスキャッシュ戦略
// server/utils/ai-cache.ts
import { useStorage } from '#imports'
const cache = useStorage('redis')
interface CacheEntry<T> {
data: T
expiresAt: number
hitCount: number
}
export async function getCachedAIResponse<T>(
key: string,
generator: () => Promise<T>,
ttl = 3600
): Promise<T> {
const cached = await cache.getItem<CacheEntry<T>>(`ai:${key}`)
if (cached && cached.expiresAt > Date.now()) {
cached.hitCount++
await cache.setItem(`ai:${key}`, cached)
return cached.data
}
const data = await generator()
await cache.setItem(`ai:${key}`, {
data,
expiresAt: Date.now() + ttl * 1000,
hitCount: 0,
})
return data
}
2. マルチモデルインテリジェントルーティング
// server/utils/model-router.ts
import type { AIModel } from '~/shared/types/ai'
interface ModelRoute {
model: AIModel
condition: (messages: any[]) => boolean
priority: number
}
const routes: ModelRoute[] = [
{
model: 'gpt-4o',
condition: (msgs) => msgs.length > 20 || msgs.some((m) => m.content.length > 2000),
priority: 10,
},
{
model: 'gpt-4o-mini',
condition: () => true,
priority: 1,
},
]
export function selectModel(messages: any[]): AIModel {
const matched = routes
.filter((r) => r.condition(messages))
.sort((a, b) => b.priority - a.priority)
return matched[0]?.model || 'gpt-4o-mini'
}
3. リクエスト重複排除とバッチ処理
// server/utils/request-dedup.ts
const pendingRequests = new Map<string, Promise<any>>()
export async function deduplicatedFetch<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
const pending = pendingRequests.get(key)
if (pending) return pending as Promise<T>
const promise = fetcher().finally(() => {
pendingRequests.delete(key)
})
pendingRequests.set(key, promise)
return promise
}
比較分析:Nuxt 4 vs Next.js 15 vs SvelteKit
| 次元 | Nuxt 4 | Next.js 15 | SvelteKit |
|---|---|---|---|
| フルスタックAI | Server Routes + Nitro | Route Handlers + Edge | Server Endpoints |
| SSRストリーミング | ネイティブサポート | App Routerサポート | サポート済みだがエコシステムが小さい |
| エッジデプロイ | Cloudflare/Vercel/Deno | Vercel Edge優先 | Cloudflareアダプター |
| 型安全性 | スタック全体で型共有 | 手動設定が必要 | 組み込みだが異なる仕組み |
| 状態管理 | Pinia(公式推奨) | サードパーティライブラリが必要 | 組み込みStores |
| 学習曲線 | Vue開発者にフレンドリー | Reactエコシステム | Svelte構文がユニーク |
| AIエコシステム | Vercel AI SDK互換 | Vercel AI SDKネイティブ | コミュニティ適合 |
| ストリーミングUI | カスタムComposable | useChat/useCompletion | コミュニティソリューション |
| バンドルサイズ | 中程度 | やや大きい | 最小 |
| 開発体験 | Auto-import + HMR | Turbopack HMR | Vite HMR |
選択ガイド:
- Vueチーム → Nuxt 4: 学習コストゼロ、完全なフルスタック機能
- Reactチーム → Next.js 15: 最も成熟したAI SDKエコシステム
- 極限のパフォーマンス追求 → SvelteKit: 最小バンドル、ただしAIエコシステムは改善待ち
オンラインツールのおすすめ
Vue3 + Nuxt 4フルスタックAIアプリの開発時に、以下のオンラインツールが生産性向上に役立ちます:
- JSONフォーマッター - AI APIレスポンスのデバッグ時にJSONデータをフォーマット
- Base64エンコード/デコード - AI APIのBase64エンコードデータを処理
- コードフォーマッター - Vue3/TypeScriptコードをフォーマット
まとめ
Vue3 + Nuxt 4フルスタックAIアプリケーションは2026年に完全に成熟しました。7つのプロダクションパターンがAPIプロキシからエッジ推論までの完全なチェーンをカバーしています:
- Server Routes はフルスタックAIの基盤——バックエンド不要、APIキーは安全
- SSR + AI により検索エンジンがAIコンテンツをインデックス、SEOとAIの両立
- ストリーミングチャット はAIアプリの中核インタラクション、中断とリトライを必須サポート
- エッジ推論 がレイテンシを一桁に削減、Cloudflare Workersが第一選択
- RAGフロントエンド には検索-生成-表示の完全なインタラクションチェーンが必要
- Pinia永続化 がページやセッションをまたいで会話状態を維持
- プロダクションデプロイ はセキュリティ、パフォーマンス、オブザーバビリティの3次元に注力
Nuxt 4により、Vue開発者は初めてNext.jsに匹敵するフルスタックAI機能を手に入れました——1つのコードベース、1回のデプロイ、フルスタックAI。
関連記事:
外部参考:
ブラウザローカルツールを無料で試す →