Nuxt 3でフルスタックアプリを構築する:Server Routes、Nitro EngineとDrizzle ORM

Development tutorial - IT technology blog
Development tutorial - IT technology blog

小規模なSaaSで6ヶ月間Nuxt 3を本番環境で運用してきて、率直に言える:これは早く知っておきたかったスタックだ。バックエンドを別に分ける必要なし、複雑なCORS設定も不要、2つのリポジトリを管理する手間もない。1つのプロジェクト、1回のデプロイ、それだけでいい。

この記事では実践を中心に進める — 5分でセットアップして、その後になぜ動くのかを解説していく。

Quick Start:5分で動かす

新しいプロジェクトを作成してdependenciesをインストールする:

npx nuxi@latest init my-fullstack-app
cd my-fullstack-app
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3

server/db/schema.tsにデータベースのschemaファイルを作成する:

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  content: text('content').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date())
})

server/db/index.tsにデータベースクライアントを作成する:

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema'

const sqlite = new Database('sqlite.db')
export const db = drizzle(sqlite, { schema })

server/api/posts/index.get.tsに最初のAPI routeを作成する:

import { db } from '~/server/db'
import { posts } from '~/server/db/schema'

export default defineEventHandler(async () => {
  return await db.select().from(posts).all()
})

migrationを実行して起動する:

npx drizzle-kit push
npm run dev

http://localhost:3000/api/postsにアクセスすれば、APIがすでに動いている。ExpressもFastifyも別途必要ない。

Nitro Engine:Expressとどう違うのか?

NitroはNuxt 3が内部で使用するサーバーランタイムだ。従来のExpressやFastifyとの最大の違いはfile-based routing — Next.jsに似ているが、サーバーサイドの話だ。

server/api/のディレクトリ構造が自動的にroutesにマッピングされる:

server/api/
├── posts/
│   ├── index.get.ts      → GET  /api/posts
│   ├── index.post.ts     → POST /api/posts
│   └── [id].get.ts       → GET  /api/posts/:id
└── users/
    └── me.get.ts         → GET  /api/users/me

特に気に入っているのは、NitroがJSON serialization、body parsing、error handlingを自動で処理してくれることだ。APIを非常にクリーンに書ける:

// server/api/posts/index.post.ts
import { db } from '~/server/db'
import { posts } from '~/server/db/schema'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  
  if (!body.title || !body.content) {
    throw createError({
      statusCode: 400,
      message: 'Title and content are required'
    })
  }

  const [newPost] = await db.insert(posts)
    .values({ title: body.title, content: body.content })
    .returning()

  return newPost
})

NitroはNode.js、Cloudflare Workers、Vercel Edge、Bunなど多数のデプロイターゲットをサポートしている — 同じコードベースで、configを変えるだけでデプロイできる。

Drizzle ORM:over-engineeredにならないtype-safeなデータベース

以前はPrismaを使っていたが、中規模プロジェクトには重すぎると感じた。Drizzleは違う — 実際のSQLに近く、bundle sizeが小さく、複雑なクエリでのパフォーマンスも優れている。

フィルターとpaginationを含むクエリの例:

// server/api/posts/index.get.ts
import { db } from '~/server/db'
import { posts } from '~/server/db/schema'
import { desc, like, sql } from 'drizzle-orm'

export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = 10
  const offset = (page - 1) * limit
  const search = query.search as string | undefined

  const conditions = search
    ? like(posts.title, `%${search}%`)
    : undefined

  const [items, [{ count }]] = await Promise.all([
    db.select()
      .from(posts)
      .where(conditions)
      .orderBy(desc(posts.createdAt))
      .limit(limit)
      .offset(offset),
    db.select({ count: sql<number>`count(*)` })
      .from(posts)
      .where(conditions)
  ])

  return {
    items,
    total: count,
    page,
    totalPages: Math.ceil(count / limit)
  }
})

TypeScript inferenceが非常によく機能する — IDEがクエリの戻り値の型を正確に把握できる。

同じプロジェクト内でFrontendとBackendを接続する

これがNuxt 3で最も気に入っている部分だ。Vueコンポーネントからinternal APIを呼び出すのは、まるで関数を呼び出すのと変わらない:

<script setup lang="ts">
// useFetchはURL baseを自動認識し、SSR hydrationも自動で処理する
const { data: posts, pending, refresh } = await useFetch('/api/posts', {
  query: { page: 1 }
})

async function createPost(title: string, content: string) {
  await $fetch('/api/posts', {
    method: 'POST',
    body: { title, content }
  })
  await refresh() // リストを再読み込み
}
</script>

<template>
  <div>
    <div v-if="pending">Loading...</div>
    <ul v-else>
      <li v-for="post in posts?.items" :key="post.id">
        {{ post.title }}
      </li>
    </ul>
  </div>
</template>

useFetch$fetchはNuxt 3の機能で、サーバーで動いているかクライアントで動いているかを自動判断し、SSRでのリクエストのdeduplicationも自動で処理する。追加設定は一切不要だ。

応用:MiddlewareとAuthentication

server middlewareでAPI routesを保護する:

// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  // /api/admin/*にのみ適用
  if (!event.path.startsWith('/api/admin')) return

  const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
  
  if (!token) {
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }

  // tokenを検証してuserをevent contextにアタッチする
  const user = await verifyToken(token)
  event.context.user = user
})

NitroのuseStorageをserver-side cachingに活用する — 中程度のトラフィックなら、Redisを追加でインストールする必要はない:

// server/api/stats.get.ts
export default defineCachedEventHandler(async () => {
  // 結果は60秒間キャッシュされる
  const stats = await db.select({
    count: sql<number>`count(*)`
  }).from(posts)
  
  return stats[0]
}, { maxAge: 60 })

Drizzle Migrations:このステップを省略しないこと

プロジェクトが大きくなったら、drizzle-kit pushの代わりにmigrationが必要になる。drizzle.config.tsファイルを作成する:

import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './server/db/schema.ts',
  out: './server/db/migrations',
  dialect: 'sqlite',
  dbCredentials: {
    url: './sqlite.db'
  }
})

Migrationのワークフロー:

# schemaを変更後にmigration fileを作成
npx drizzle-kit generate

# migrationを適用
npx drizzle-kit migrate

# migrationの状態を確認
npx drizzle-kit studio

50K linesのコードベースをリファクタリングした経験から学んだ最大の教訓は、着手前に十分なtest coverageを用意することだ。database migrationも同様 — 本番環境に適用する前に必ずstagingでテストし、migration filesをgitで管理してチームが簡単に同期できるようにしておこう。

6ヶ月の本番運用で得た実践的なTips

1. db clientはsingletonとして分離する — リクエストごとに新しいconnectionを作らないこと。server/db/index.tsはNode.jsのmodule cachingのおかげで一度だけ作成される。

2. startup logicにはserver/plugins/を使う

// server/plugins/migrations.ts
export default defineNitroPlugin(async () => {
  // サーバー起動時に自動的にmigrationを実行
  const { migrate } = await import('drizzle-orm/better-sqlite3/migrator')
  migrate(db, { migrationsFolder: './server/db/migrations' })
  console.log('Database migrations applied')
})

3. ZodでinputをValidateする — Nitroはbuilt-in validationを持っていないため、Zodとの組み合わせが最善の選択だ:

npm install zod
import { z } from 'zod'

const createPostSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(10)
})

export default defineEventHandler(async (event) => {
  const raw = await readBody(event)
  const body = createPostSchema.parse(raw) // invalidの場合はZodErrorをthrow
  // ...
})

4. small-mediumプロジェクトにはSQLite、スケールが必要ならPostgreSQL — このスタックは数万ユーザーまでSQLiteで十分動く。スケールが必要になったら、driverを交換してdrizzle configを更新するだけでPostgreSQLに移行できる。ビジネスロジックは変更不要だ。

5. .envruntimeConfigを忘れずに

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    databaseUrl: process.env.DATABASE_URL, // server-only
    public: {
      apiBase: '/api' // exposed to client
    }
  }
})

このスタックをいつ使うべきか?

Nuxt 3 + Nitro + Drizzleのスタックが適しているのは以下の場合だ:

  • 小規模チームで1つのリポジトリにフルスタックをまとめたい場合
  • 中規模プロジェクト — SaaS、社内ツール、インタラクティブなブログ
  • SEOのためにSSR/SSGが必要だが、動的なAPIも必要な場合
  • 必要になるまでmicroservicesを管理したくない場合

バックエンドが非常に複雑な場合(多数のbackground jobs、複雑なWebSocket、バックエンドの独立したhorizontal scalingが必要)には適さない — その場合は分離する方が合理的だ。

しかし、実際に経験したプロジェクトの80%では、このスタックで十分すぎるほどだ。月$10のVPSにpm2でデプロイするか、Vercelに直接pushするだけで完了する。

Share: