Sync
Handbook
【第1回】環境構築とプロジェクト設計【第4回】DB設計とAlembicマイグレーション
【第2回】htmx でSPA風ダッシュボードを作る【第3回】ダークテーマUIとコンポーネント設計
【第5回】証券APIとの接続npm install さようなら — FastAPI + htmx でトレーディングシステムを作るnpm install さようなら — FastAPI + htmx でトレーディングシステムを作る
npm install さようなら — FastAPI + htmx でトレーディングシステムを作る
Zennライクな記事プラットフォームを自作するZennライクな記事プラットフォームを自作するClaude Code「スキル」と「エージェント」徹底解説
Google GeminiとImagen 4の全貌
OpenAI / ChatGPTAI & 機械学習アイデア & ノート

その他

Zennライクな記事プラットフォームを自作するGemini画像生成技術のレビュー

© 2026 Sync. All rights reserved.

Tech
2026/2/14

Zennライクな記事プラットフォームを自作する

Part 3: Drizzle ORM + Turso (SQLite) でDB設計する

Zennライクな記事プラットフォームを自作する

こんにちは、「Zennライクな記事プラットフォームを自作する」シリーズのPart 3です。今回は、このプラットフォームのデータベース層を支える Drizzle ORM と Turso (libSQL) について、選定理由から9テーブルのスキーマ設計、ローカルとリモートの切り替え構成まで、実際のコードを交えて詳しく解説していきます。

この記事で学べること

  • Drizzle ORM の特徴と選定理由
  • Turso (libSQL) とは何か、なぜ SQLite ベースの DB を選んだか
  • 記事プラットフォームに必要な 9テーブル のスキーマ設計
  • ローカル開発 (better-sqlite3) とリモート (Turso) の 切り替え構成
  • drizzle.config.ts の設定と DB 管理コマンドの使い方
  • Turso CLI のセットアップ手順

目次

  1. Drizzle ORM を選んだ理由
  2. Turso (libSQL) とは
  3. 9テーブルのスキーマ設計
  4. DB接続の構成 - ローカルとリモートの切り替え
  5. drizzle.config.ts の設定
  6. DB管理コマンドの使い方
  7. Turso CLI のセットアップ
  8. まとめ

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 (`