サーバーサイドAPIルート
BobはMegaShopにバックエンドAPIを追加する必要があります。商品CRUD操作, ショッピングカート機能, ユーザー認証などです。従来のアプローチでは別のExpressサーバーをデプロイする必要があり, フロントエンドとバックエンドの統合は頭痛の種でした。CharlieはNuxt 3ならserver/ディレクトリに直接APIを書けることを発見し, 1つのプロジェクトでフロントエンドとバックエンドの両方を処理できます。
1. 学ぶ内容
- server/api/ルート定義とHTTPメソッドマッピング
- イベントハンドリング:defineEventHandler / readBody / getQuery / getRouterParam
- server/middleware/グローバルインターセプト
- クロスドメインとCORS処理
- MegaShop /api/products / /api/cart / /api/auth APIのハンズオンガイド
2. 管理者の実話
(1) ペインポイント:フロントエンドとバックエンド分離の統合の悪夢
BobはExpressを使ってスタンドアロンのAPIサーバーを書き, ポート4000で実行していました。Nuxtフロントエンドはポート3000で実行しています。開発中はプロキシを設定してクロスオリジンリクエストを処理する必要があり, デプロイには2つのCI/CDパイプラインが必要でした。Aliceのショッピングカートリクエストはクロスオリジン問題で時々ブロックされ, 本番環境でエラーが発生していました。
(2) NuxtサーバーAPIによる解決策
Nuxt 3ではserver/ディレクトリにプロジェクト内で直接APIを書けます。同じドメインとポートにあるため, クロスドメイン問題はありません:
TYPESCRIPT
// server/api/products/index.get.ts
export default defineEventHandler(() => {
return { items: products, total: 1000000 }
})
(3) 効果:オールインワンフルスタックソリューション
Bobは1つのプロジェクトのみを管理し, APIとフロントエンドは同じドメインにデプロイされるため, Aliceはもうクロスドメインエラーに遭遇することはなく, デプロイプロセスは50%簡素化されました。
3. APIルート定義
(1) ファイルルーティングマッピングルール
| ファイルパス | HTTPメソッド | ルートURL |
|---|---|---|
| server/api/products.ts | ALL | /api/products |
| server/api/products/index.ts | ALL | /api/products |
| server/api/products/index.get.ts | GET | /api/products |
| server/api/products/index.post.ts | POST | /api/products |
| server/api/products/[id].get.ts | GET | /api/products/:id |
| server/api/products/[id].put.ts | PUT | /api/products/:id |
| server/api/products/[id].delete.ts | DELETE | /api/products/:id |
| server/api/auth/login.post.ts | POST | /api/auth/login |
(2) APIリクエスト処理ライフサイクル
sequenceDiagram
participant C as クライアント
participant M as サーバーミドルウェア
participant H as イベントハンドラ
participant D as データソース
C->>M: HTTPリクエスト
M->>M: 認証チェック / CORS / ロギング
M->>H: イベントを渡す
H->>D: クエリ / ミューテーション
D-->>H: データ結果
H-->>M: HTTPレスポンス
M-->>C: JSONレスポンス
(1) ▶ サンプル:GET商品一覧
TYPESCRIPT
// server/api/products/index.get.ts
export default defineEventHandler((event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const category = query.category as string
let filtered = mockProducts
if (category) {
filtered = filtered.filter(p => p.category === category)
}
const start = (page - 1) * limit
return {
items: filtered.slice(start, start + limit),
total: filtered.length,
page,
limit
}
})
出力:
TEXT
// 実行成功
(2) ▶ サンプル:POST商品作成
TYPESCRIPT
// server/api/products/index.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// 必須フィールドの検証
if (!body.name || !body.price) {
throw createError({
statusCode: 400,
message: 'Name and price are required'
})
}
const newProduct = {
id: mockProducts.length + 1,
name: body.name,
price: Number(body.price),
category: body.category || 'uncategorized',
inStock: body.inStock ?? true,
image: body.image || '/images/placeholder.webp'
}
mockProducts.push(newProduct)
return { product: newProduct, message: 'Product created successfully' }
})
出力:
TEXT
// 実行成功
(3) ▶ サンプル:動的パラメータルート
TYPESCRIPT
// server/api/products/[id].get.ts
export default defineEventHandler((event) => {
const id = Number(getRouterParam(event, 'id'))
const product = mockProducts.find(p => p.id === id)
if (!product) {
throw createError({
statusCode: 404,
message: 'Product not found'
})
}
return product
})
出力:
TEXT
// 実行成功
(4) ▶ サンプル:PUT商品更新
TYPESCRIPT
// server/api/products/[id].put.ts
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
const body = await readBody(event)
const index = mockProducts.findIndex(p => p.id === id)
if (index === -1) {
throw createError({ statusCode: 404, message: 'Product not found' })
}
mockProducts[index] = { ...mockProducts[index], ...body }
return { product: mockProducts[index], message: 'Product updated' }
})
出力:
TEXT
// 実行成功
(5) ▶ サンプル:DELETE商品削除
TYPESCRIPT
// server/api/products/[id].delete.ts
export default defineEventHandler((event) => {
const id = Number(getRouterParam(event, 'id'))
const index = mockProducts.findIndex(p => p.id === id)
if (index === -1) {
throw createError({ statusCode: 404, message: 'Product not found' })
}
const deleted = mockProducts.splice(index, 1)
return { product: deleted[0], message: 'Product deleted' }
})
出力:
TEXT
// 実行成功
4. イベントハンドリングユーティリティ関数
(1) リクエストツールクイックリファレンス
| 関数 | 目的 | 例 |
|---|---|---|
| getQuery(event) | URLクエリパラメータを取得 | { page: '1', limit: '20' } |
| getRouterParam(event, key) | ルートパラメータを取得 | /products/123 → '123' |
| readBody(event) | リクエストボディを読み取り | { name: 'Product', price: 99 } |
| getHeader(event, key) | リクエストヘッダーを取得 | 'Bearer token...' |
| getCookie(event, key) | Cookieを取得 | 'session-id-xxx' |
| setCookie(event, key, val, opts) | Cookieを設定 | setCookie(event, 'token', jwt, { httpOnly: true }) |
| setHeader(event, key, val) | レスポンスヘッダーを設定 | setHeader(event, 'x-total', '1000') |
| createError(opts) | エラーをスロー | createError({ statusCode: 404 }) |
(2) レスポンスフォーマット規約
| シナリオ | ステータスコード | レスポンスボディ |
|---|---|---|
| 検索成功 | 200 | { items: [], total: 0 } |
| 作成成功 | 201 | { product: {}, message: '...' } |
| 更新成功 | 200 | { product: {}, message: '...' } |
| 削除成功 | 200 | { message: '...' } |
| パラメータエラー | 400 | { statusCode: 400, message: '...' } |
| 未認証 | 401 | { statusCode: 401, message: '...' } |
| 見つからない | 404 | { statusCode: 404, message: '...' } |
5. サーバーミドルウェア
(1) ▶ サンプル:グローバルログミドルウェア
TYPESCRIPT
// server/middleware/logger.ts
export default defineEventHandler((event) => {
const start = Date.now()
const method = getMethod(event)
const url = getRequestURL(event)
// レスポンス後にログ出力
event.node.res.on('finish', () => {
const duration = Date.now() - start
const status = event.node.res.statusCode
console.log(`${method} ${url} → ${status} (${duration}ms)`)
})
})
出力:
TEXT
// 実行成功
(2) ▶ サンプル:認証ミドルウェア
TYPESCRIPT
// server/middleware/auth.ts
export default defineEventHandler((event) => {
const protectedPaths = ['/api/admin', '/api/orders', '/api/cart']
const url = getRequestURL(event)
const isProtected = protectedPaths.some(path => url.pathname.startsWith(path))
if (!isProtected) return
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) {
throw createError({ statusCode: 401, message: 'Authentication required' })
}
// JWTトークンを検証 (簡略化)
try {
const payload = verifyToken(token)
event.context.user = payload
} catch {
throw createError({ statusCode: 401, message: 'Invalid token' })
}
})
出力:
TEXT
// 実行成功
6. CORSクロスオリジン処理
(1) ▶ サンプル:Nitro内蔵CORS設定
TYPESCRIPT
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/api/**': { cors: true }
}
})
出力:
TEXT
// 実行成功
(2) ▶ サンプル:カスタムCORSミドルウェア
TYPESCRIPT
// server/middleware/cors.ts
export default defineEventHandler((event) => {
const origin = getHeader(event, 'origin') || '*'
setHeader(event, 'Access-Control-Allow-Origin', origin)
setHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
setHeader(event, 'Access-Control-Max-Age', '86400')
// プリフライトを処理
if (getMethod(event) === 'OPTIONS') {
event.node.res.statusCode = 204
return ''
}
})
出力:
TEXT
// 実行成功
(1) CORSソリューションの比較
| ソリューション | 設定方法 | 柔軟性 | ユースケース |
|---|---|---|---|
| routeRules cors: true | nuxt.config.ts | 低 (すべてオン/オフ) | 開発環境 |
| カスタムミドルウェア | server/middleware/ | 高 (きめ細かい) | 本番環境 |
| Nitro routeRules高度設定 | routeRulesルートごと | 中 | 混在要件 |
7. 総合例:MegaShop APIフレームワーク
TYPESCRIPT
// server/api/cart/index.get.ts - カート商品を取得
export default defineEventHandler((event) => {
const userId = event.context.user?.id
if (!userId) {
throw createError({ statusCode: 401, message: 'Login required' })
}
const cart = mockCarts.find(c => c.userId === userId)
return cart || { userId, items: [], total: 0 }
})
TYPESCRIPT
// server/api/cart/index.post.ts - カートに商品を追加
export default defineEventHandler(async (event) => {
const userId = event.context.user?.id
const { productId, quantity = 1 } = await readBody(event)
const product = mockProducts.find(p => p.id === productId)
if (!product) {
throw createError({ statusCode: 404, message: 'Product not found' })
}
if (!product.inStock) {
throw createError({ statusCode: 400, message: 'Product out of stock' })
}
// カート商品を追加または更新
let cart = mockCarts.find(c => c.userId === userId)
if (!cart) {
cart = { userId, items: [], total: 0 }
mockCarts.push(cart)
}
const existing = cart.items.find(i => i.productId === productId)
if (existing) {
existing.quantity += quantity
} else {
cart.items.push({ productId, name: product.name, price: product.price, quantity })
}
cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
return { cart, message: 'Item added to cart' }
})
❓ よくある質問
Q
server/ディレクトリのコードはクライアントバンドルに含まれますか?A いいえ。Nitroは
server/のコードを別々にバンドルするため, クライアントに漏れることはありません。サーバーコードはNode.js APIやシークレットを安全に使用できます。Q APIルートファイル名の.getや.postサフィックスは必須ですか?
A いいえ, 必須ではありません。メソッドサフィックスのないファイルはすべてのHTTPメソッドを処理します。サフィックスを追加すると各メソッドの処理ロジックを精密に制御できるため, 推奨されています。
Q
createErrorとthrow new Errorの違いは何ですか?A
createErrorはH3が提供し, 正しいHTTPステータスコードとJSONレスポンスを返します。throw new Errorは500内部エラーを返します。APIルートではcreateErrorの使用を推奨します。Q サーバーミドルウェアとクライアントミドルウェアの違いは何ですか?
A サーバーミドルウェアは
server/middleware/にあり, APIリクエストをインターセプトします (認証, ロギング, CORS)。クライアントミドルウェアはmiddleware/ディレクトリにあり, ページルート遷移をインターセプトします (権限ガード/リダイレクト)。Q APIで
runtimeConfigのプライベート変数を使用するにはどうすればよいですか?A
useRuntimeConfig()で取得するだけです。プライベート変数はサーバーでのみ利用可能です:const config = useRuntimeConfig() → config.databaseUrl。Q APIはストリームレスポンス (SSE)を返せますか?
A はい。
event.node.res.write()でデータをチャンクで書き込み, Content-Type: text/event-streamを設定してSSEを実装できます。NitroはネイティブのNode.jsレスポンスAPIを完全にサポートしています。📖 まとめ
- Nuxt 3の
server/ディレクトリがAPI:ファイルパスがルートにマッピングされ, メソッドサフィックスがHTTPメソッドにマッピングされる - defineEventHandler + readBody/getQuery/getRouterParamでリクエストを処理
- サーバーミドルウェアはグローバルインターセプター:ロギング, 認証, CORS
- 同一ドメイン内にデプロイすることでクロスドメイン問題を自然に回避。必要に応じて
routeRules corsやカスタムミドルウェアを使用可能 - MegaShopは/api/products, /api/cart, /api/authで完全なバックエンドを構築
📝 練習問題
- 基本問題 (難易度:⭐):GET /api/productsとGET /api/products/[id]エンドポイントを作成し, モック商品データを返してください。
- 応用問題 (難易度:⭐⭐):完全なCRUD API (GET/POST/PUT/DELETE)を実装し, パラメータ検証とエラー処理を含めてください。
- チャレンジ (難易度:⭐⭐⭐):サーバーミドルウェアで認証を実装してください。未認証ユーザーが
/api/cartにアクセスした場合は401エラーを返し, ログイン後は正常に操作できるようにしてください。
---|



