1. 結論(この記事で得られること)
この記事を読むと、以下が手に入ります。
- Nuxt3の「server/api」ディレクトリで 安全かつ保守しやすい API実装パターン
- エラーハンドリング・バリデーション・認証の 実装レベルのベストプラクティス
- 実務で遭遇する「なぜか動かない」を AIと協働で最速解決 する手順
- レビューで「これはNG」と言われないための 設計判断基準
私自身、最初にNuxt3のサーバールートを触ったとき「フロントとサーバーが同じリポジトリって、どこまで責務を寄せていいの?」と迷いました。結果、フロントの都合でビジネスロジックをAPIに書いてしまい、テストもメンテも地獄を見ました。
この記事では、そんな失敗を踏まえた 実務で使える実装パターン だけを厳選します。
2. 前提(環境・読者層)
想定読者
- Nuxt3でフルスタック開発を始めた方
- Vue/Reactのフロント経験はあるが、サーバーサイド設計に不安がある方
- 「とりあえず動く」から「チームで保守できる」にレベルアップしたい方
Nuxt: 3.10以降
Node.js: 18以降
TypeScript: 5.x
前提知識
- Nuxt3の基本的なディレクトリ構成(pages, components)
- async/awaitの基礎
- REST APIの概念
3. Before:よくあるつまずきポイント
実務でよく見る(そして自分も通った)アンチパターンを3つ挙げます。
3-1. すべてを一つのファイルに詰め込む
// ❌ server/api/users.ts
export default defineEventHandler(async (event) => {
const method = event.node.req.method
if (method === 'GET') {
// 取得処理
const db = await connectDB()
const users = await db.query('SELECT * FROM users')
return users
} else if (method === 'POST') {
// 作成処理
const body = await readBody(event)
// バリデーションもロジックもすべてここ
if (!body.email) throw new Error('email required')
// ...100行続く
}
})
問題点
- HTTPメソッドごとに処理を分岐させると可読性が崩壊
- テストが書けない(すべてが結合している)
- エラーハンドリングが統一されていない
3-2. 認証チェックの重複
// ❌ 各エンドポイントでコピペ
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
if (!token) {
throw createError({ statusCode: 401 })
}
const user = await verifyToken(token)
if (!user) {
throw createError({ statusCode: 401 })
}
// 本来の処理
})
問題点
- 認証ロジックが散在(修正時に漏れる)
- テストで毎回モックが必要
- 権限管理を後から追加できない
3-3. エラーレスポンスの不統一
// ❌ エンドポイントごとにバラバラ
// /api/users → { error: 'not found' }
// /api/posts → { message: 'error', code: 404 }
// /api/comments → throw new Error('...')
フロント側で 「error.data?.error || error.data?.message」 みたいな分岐が発生します。これ、昔やってました…。
4. After:基本的な解決パターン
4-1. ファイルベースルーティングを活用
Nuxt3では ファイル名とHTTPメソッドを対応 させられます。
server/api/
users/
index.get.ts # GET /api/users
index.post.ts # POST /api/users
[id].get.ts # GET /api/users/:id
[id].patch.ts # PATCH /api/users/:id
[id].delete.ts # DELETE /api/users/:id
実装例
// server/api/users/index.get.ts
export default defineEventHandler(async (event) => {
const users = await getUsers()
return users
})
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const user = await getUserById(id)
if (!user) {
throw createError({
statusCode: 404,
message: 'User not found'
})
}
return user
})
メリット
- 1ファイル1責務で見通しが良い
- テストファイルと対応させやすい
- HTTPメソッドが明確
4-2. Middlewareで共通処理を集約
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
// /api/auth 配下は認証不要
if (event.path.startsWith('/api/auth')) {
return
}
const token = getCookie(event, 'auth_token')
if (!token) {
throw createError({
statusCode: 401,
message: 'Authentication required'
})
}
const user = await verifyToken(token)
if (!user) {
throw createError({
statusCode: 401,
message: 'Invalid token'
})
}
// contextにユーザー情報を格納
event.context.user = user
})
// server/api/users/me.get.ts
export default defineEventHandler(async (event) => {
// middlewareで認証済み
const user = event.context.user
return user
})
ポイント
- 「server/middleware」配下のファイルは すべてのAPIリクエストに適用
- 順序制御が必要なら「01.auth.ts」のように番号接頭辞を使う
- 「event.context」で後続ハンドラーにデータ引き継ぎ
4-3. 統一エラー型とユーティリティ
// server/utils/error.ts
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message)
}
}
export const createAppError = (
statusCode: number,
message: string,
code?: string
) => {
throw createError({
statusCode,
message,
data: { code }
})
}
// server/api/users/[id].delete.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const deleted = await deleteUser(id)
if (!deleted) {
createAppError(404, 'User not found', 'USER_NOT_FOUND')
}
return { success: true }
})
フロント側で統一された形式で受け取れます。
// pages/users/[id].vue
const { data, error } = await useFetch(`/api/users/${id}`, {
method: 'DELETE'
})
if (error.value) {
console.log(error.value.data.code) // 'USER_NOT_FOUND'
}