小規模な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. .envとruntimeConfigを忘れずに:
// 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するだけで完了する。

