Zennライクな記事プラットフォームを自作する
Part 3: Drizzle ORM + Turso (SQLite) でDB設計する

こんにちは、「Zennライクな記事プラットフォームを自作する」シリーズのPart 3です。今回は、このプラットフォームのデータベース層を支える Drizzle ORM と Turso (libSQL) について、選定理由から9テーブルのスキーマ設計、ローカルとリモートの切り替え構成まで、実際のコードを交えて詳しく解説していきます。
この記事で学べること
- Drizzle ORM の特徴と選定理由
- Turso (libSQL) とは何か、なぜ SQLite ベースの DB を選んだか
- 記事プラットフォームに必要な 9テーブル のスキーマ設計
- ローカル開発 (better-sqlite3) とリモート (Turso) の 切り替え構成
drizzle.config.tsの設定と DB 管理コマンドの使い方- Turso CLI のセットアップ手順
目次
- Drizzle ORM を選んだ理由
- Turso (libSQL) とは
- 9テーブルのスキーマ設計
- DB接続の構成 - ローカルとリモートの切り替え
- drizzle.config.ts の設定
- DB管理コマンドの使い方
- Turso CLI のセットアップ
- まとめ
1. Drizzle ORM を選んだ理由
Node.js / TypeScript の ORM といえば、Prisma が圧倒的に有名です。では、なぜ今回 Drizzle ORM を選んだのか。理由は大きく3つあります。
型安全性がネイティブ
Drizzle ORM はスキーマ定義がそのまま TypeScript の型になります。Prisma のように別途 prisma generate で型を生成する必要がありません。スキーマを書いた瞬間から型推論が効く ので、開発体験がとてもスムーズです。
軽量でゼロ依存
Drizzle ORM の本体は非常に軽量です。Prisma のようにバイナリエンジンを含まないため、デプロイサイズが小さく なります。サーバーレス環境や Edge Runtime でも問題なく動作する点は大きな利点ですね。
SQLite / Turso に対するファーストクラスサポート
これが決め手でした。Drizzle ORM は SQLite、そして Turso (libSQL) を dialect (方言) レベルでサポート しています。Prisma も SQLite 対応はありますが、Turso との統合は Drizzle のほうが自然で、設定もシンプルです。
| 比較項目 | Drizzle ORM | Prisma |
|---|---|---|
| 型生成 | スキーマから自動推論 | prisma generate が必要 |
| バンドルサイズ | 軽量 (ゼロ依存) | バイナリエンジン含む |
| SQLite / Turso | ファーストクラスサポート | 基本的な対応 |
| SQL に近い記法 | そのまま SQL を書く感覚 | 独自のクエリ API |
| マイグレーション | drizzle-kit push で即反映 |
prisma migrate |
2. Turso (libSQL) とは
Turso は、SQLite をベースにした エッジ対応のリモートデータベースサービス です。libSQL という SQLite のフォーク (派生版) を使用しています。
なぜ Turso を選んだか
- SQLite 互換: ローカル開発では
better-sqlite3でファイルベースの SQLite を使い、本番では Turso のリモート DB を使う、というシームレスな切り替えが可能です - エッジ対応: 東京リージョン (aws-ap-northeast-1) にデプロイでき、低レイテンシでアクセスできます
- 無料枠が十分: 個人プロジェクトや小規模サービスなら無料プランで十分に運用できます
- セットアップが簡単: CLI から数コマンドで DB を作成でき、URL + トークンだけで接続できます
PostgreSQL や MySQL のようなフル機能の RDBMS が必要なほど大規模ではない、けれど SQLite のファイルベースだけでは本番運用が心もとない。Turso はそのちょうど中間を埋めてくれる存在です。
3. 9テーブルのスキーマ設計
このプラットフォームのスキーマは、大きく 3つのグループ に分類できます。
画像: [9テーブルの ER 図 - 認証系、コンテンツ系、課金系の3グループ]
グループ1: 認証系 (NextAuth 管理)
NextAuth (Auth.js) が管理するテーブル群です。user, account, session, verificationToken の4テーブルがこれに該当します。
user テーブル
ユーザーの基本情報を保持するテーブルです。NextAuth の標準カラムに加えて、role と stripeCustomerId を独自に追加しています。
export const users = sqliteTable("user", {
id: text("id")
.primaryKey()
.$default(() => crypto.randomUUID()),
name: text("name"),
email: text("email").unique(),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
image: text("image"),
// 独自カラム: ロール管理
role: text("role", { enum: ["user", "admin"] })
.notNull()
.default("user"),
// 独自カラム: Stripe 顧客 ID
stripeCustomerId: text("stripeCustomerId"),
createdAt: integer("createdAt", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
});
ポイントは role カラムです。"user" と "admin" の2値を enum で定義しており、管理者判定に使います。stripeCustomerId は Stripe での課金処理にユーザーを紐付けるために使います。
また、createdAt のデフォルト値に sql`(unixepoch() * 1000)` を使っている点に注目してください。これは SQLite のネイティブ関数で、現在のUNIXタイムスタンプをミリ秒単位で取得しています。Drizzle ORM では sql テンプレートリテラルで生の SQL を埋め込めます。
account テーブル
OAuth プロバイダ (今回は Google) のアカウント情報を保持します。(provider, providerAccountId) の 複合主キー になっています。
export const accounts = sqliteTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccountType>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => [
primaryKey({
columns: [account.provider, account.providerAccountId],
}),
]
);
$type<AdapterAccountType>() で NextAuth の型を直接適用しているのがポイントです。Drizzle ORM ならではの型安全な書き方ですね。
session / verificationToken テーブル
session はセッション管理、verificationToken はメール確認用トークンの管理テーブルです。いずれも NextAuth の標準的な構成に沿っています。
グループ2: コンテンツ系
プラットフォームの核となるコンテンツ管理テーブルです。
series テーブル
記事をまとめるシリーズ (連載) を管理します。Zenn の「本 (Book)」に近い概念です。
export const series = sqliteTable("series", {
id: text("id")
.primaryKey()
.$default(() => crypto.randomUUID()),
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
description: text("description"),
emoji: text("emoji"),
articleType: text("articleType"),
sortOrder: integer("sortOrder").notNull().default(0),
createdAt: integer("createdAt", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
});
article テーブル
最も多くのカラムを持つ中心的なテーブルです。特徴的なのは コンテンツを3つのカラムに分けて保持 している点です。
export const articles = sqliteTable("article", {
id: text("id")
.primaryKey()
.$default(() => crypto.randomUUID()),
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
description: text("description"),
coverImage: text("coverImage"),
content: text("content"), // Tiptap JSON (編集用の原本)
freeContent: text("freeContent"), // 無料公開部分の HTML
paidContent: text("paidContent"), // 有料部分の HTML
status: text("status", { enum: ["draft", "published"] })
.notNull()
.default("draft"),
pricing: text("pricing", { enum: ["free", "paid", "subscription"] })
.notNull()
.default("free"),
price: integer("price"), // 価格 (JPY)
tags: text("tags"), // JSON 文字列配列
articleType: text("articleType"), // "tech" | "idea"
subtitle: text("subtitle"),
emoji: text("emoji"),
authorId: text("authorId").references(() => users.id, { onDelete: "set null" }),
seriesId: text("seriesId").references(() => series.id, { onDelete: "set null" }),
seriesOrder: integer("seriesOrder").default(0),
publishedAt: integer("publishedAt", { mode: "timestamp_ms" }),
createdAt: integer("createdAt", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updatedAt", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
});
content: Tiptap エディタの JSON 形式データ。編集時の原本として使いますfreeContent: PaywallDivider (`