404 Not Found

404 Not Found


nginx

サーバーサイドAPIルート

BobはMegaShopにバックエンドAPIを追加する必要があります。商品CRUD操作, ショッピングカート機能, ユーザー認証などです。従来のアプローチでは別のExpressサーバーをデプロイする必要があり, フロントエンドとバックエンドの統合は頭痛の種でした。CharlieはNuxt 3ならserver/ディレクトリに直接APIを書けることを発見し, 1つのプロジェクトでフロントエンドとバックエンドの両方を処理できます。

1. 学ぶ内容


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リクエスト処理ライフサイクル

100%
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 createErrorthrow 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を完全にサポートしています。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度:⭐):GET /api/productsとGET /api/products/[id]エンドポイントを作成し, モック商品データを返してください。
  2. 応用問題 (難易度:⭐⭐):完全なCRUD API (GET/POST/PUT/DELETE)を実装し, パラメータ検証とエラー処理を含めてください。
  3. チャレンジ (難易度:⭐⭐⭐):サーバーミドルウェアで認証を実装してください。未認証ユーザーが/api/cartにアクセスした場合は401エラーを返し, ログイン後は正常に操作できるようにしてください。

---|

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%