設計

MDXベースの記事に対する「いいね」機能のテーブル設計ガイド

Next.js + fumadocs + Better Auth + Turso + Drizzleの環境で、MDXファイルで管理された記事に対していいね機能を実装するためのテーブル設計を解説

MDXファイルで管理された記事に対して「いいね」機能を実装する際、どのようなテーブル設計が適切かを解説します。

前提条件

  • フレームワーク: Next.js 16
  • ドキュメント管理: fumadocs(MDXファイルベース)
  • 認証: Better Auth
  • データベース: Turso(libSQL / SQLite互換)
  • ORM: Drizzle ORM

記事はデータベースで管理せず、content/articles/*.mdx として配置されています。

なぜ記事をDBで管理しなくても良いのか

MDXファイルの特性

fumadocsでは、MDXファイルのファイル名(拡張子を除いた部分)がslugとして機能します。例えば:

  • content/articles/knip-introduction-guide.mdx → slug: knip-introduction-guide
  • content/articles/t3-env-introduction-guide.mdx → slug: t3-env-introduction-guide

このslugは以下の理由から、記事の一意識別子として十分に機能します:

  1. ファイルシステムによる一意性保証: 同じディレクトリ内に同名ファイルは存在できない
  2. URLとの直接対応: slugがそのままURLパスになる
  3. 変更の追跡可能性: Gitで管理されるため、変更履歴が残る
  4. シンプルな運用: DBとファイルの同期が不要

記事テーブルを作らない設計のメリット

観点記事テーブルあり記事テーブルなし(推奨)
データ整合性同期処理が必要常に一致
運用コストマイグレーション管理MDXファイルのみ
パフォーマンスJOINが必要直接参照
柔軟性スキーマ変更が必要frontmatterで自由に拡張

テーブル設計

推奨設計: articleLikesテーブル

「誰が」「どの記事を」いいねしたかを記録するシンプルな中間テーブルを設計します。

CREATE TABLE article_likes (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
  article_slug TEXT NOT NULL,
  created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
  updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
  UNIQUE(user_id, article_slug)
);

CREATE INDEX article_likes_user_id_idx ON article_likes(user_id);
CREATE INDEX article_likes_article_slug_idx ON article_likes(article_slug);

設計の根拠

1. 複合ユニーク制約 vs 複合主キー

複合ユニーク制約を採用した理由:

// 複合主キー方式
primaryKey({ columns: [t.userId, t.articleSlug] })

// 複合ユニーク制約 + サロゲートキー方式(推奨)
id: text("id").primaryKey(),
// + UNIQUE(user_id, article_slug)
観点複合主キーサロゲートキー + ユニーク制約
API設計2つのパラメータが必要単一IDで操作可能
外部キー参照複合キーを参照(複雑)単一キーを参照(シンプル)
Better Auth互換性不明既存のパターンと一致
将来の拡張性制限あり柔軟

Better Authの既存テーブル(user, session, account)はすべてテキスト型の単一主キー(id)を使用しています。この設計パターンに合わせることで、一貫性のあるコードベースを維持できます。

2. article_slugカラムの型

articleSlug: text("article_slug").notNull()

外部キー制約を付けない理由:

  • 記事テーブルが存在しないため、外部キー制約は不可能
  • slugはMDXファイル名から導出されるため、アプリケーション層で検証
  • MDXファイル削除時、関連するいいねを保持するか削除するかは運用ポリシー次第

3. ON DELETE CASCADEの適用

userId: text("user_id")
  .notNull()
  .references(() => user.id, { onDelete: "cascade" })

ユーザー削除時にそのユーザーのいいねも自動削除されます。これは:

  • データ整合性を保証
  • 孤児レコードの防止
  • Better Authの既存設計(session, account)と一致

Drizzle ORMによる実装

スキーマ定義

// src/db/schema/article-likes.ts
import { relations, sql } from "drizzle-orm";
import { index, sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
import { user } from "./auth";

export const articleLikes = sqliteTable(
  "article_likes",
  {
    id: text("id").primaryKey(),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    articleSlug: text("article_slug").notNull(),
    createdAt: integer("created_at", { mode: "timestamp_ms" })
      .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
      .notNull(),
    updatedAt: integer("updated_at", { mode: "timestamp_ms" })
      .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
      .$onUpdate(() => new Date())
      .notNull(),
  },
  (table) => [
    uniqueIndex("article_likes_user_article_idx").on(
      table.userId,
      table.articleSlug
    ),
    index("article_likes_user_id_idx").on(table.userId),
    index("article_likes_article_slug_idx").on(table.articleSlug),
  ]
);

export const articleLikesRelations = relations(articleLikes, ({ one }) => ({
  user: one(user, {
    fields: [articleLikes.userId],
    references: [user.id],
  }),
}));

userテーブルのリレーション更新

既存のauth.tsに以下のリレーションを追加します:

// src/db/schema/auth.ts の userRelations を更新
import { articleLikes } from "./article-likes";

export const userRelations = relations(user, ({ many }) => ({
  sessions: many(session),
  accounts: many(account),
  articleLikes: many(articleLikes), // 追加
}));

スキーマのエクスポート

// src/db/schema/index.ts
export * from "./auth";
export * from "./article-likes"; // 追加

インデックス設計の根拠

必要なクエリパターン

クエリ用途使用するインデックス
ユーザーがいいねした記事一覧マイページarticle_likes_user_id_idx
記事のいいね数記事表示article_likes_article_slug_idx
特定ユーザーが特定記事をいいね済みかいいねボタン状態article_likes_user_article_idx
いいね追加/削除いいね操作article_likes_user_article_idx

ユニークインデックスの役割

uniqueIndex("article_likes_user_article_idx").on(
  table.userId,
  table.articleSlug
)

このインデックスは以下の2つの役割を果たします:

  1. 一意性制約: 同じユーザーが同じ記事に複数回いいねすることを防止
  2. クエリ最適化: ユーザー×記事の組み合わせ検索を高速化

クエリ例

いいね追加

import { db } from "@/db";
import { articleLikes } from "@/db/schema";
import { nanoid } from "nanoid";

export async function addLike(userId: string, articleSlug: string) {
  await db.insert(articleLikes).values({
    id: nanoid(),
    userId,
    articleSlug,
  });
}

いいね削除

import { db } from "@/db";
import { articleLikes } from "@/db/schema";
import { and, eq } from "drizzle-orm";

export async function removeLike(userId: string, articleSlug: string) {
  await db
    .delete(articleLikes)
    .where(
      and(
        eq(articleLikes.userId, userId),
        eq(articleLikes.articleSlug, articleSlug)
      )
    );
}

いいね状態の確認

import { db } from "@/db";
import { articleLikes } from "@/db/schema";
import { and, eq } from "drizzle-orm";

export async function isLiked(userId: string, articleSlug: string) {
  const result = await db
    .select({ id: articleLikes.id })
    .from(articleLikes)
    .where(
      and(
        eq(articleLikes.userId, userId),
        eq(articleLikes.articleSlug, articleSlug)
      )
    )
    .limit(1);

  return result.length > 0;
}

記事のいいね数取得

import { db } from "@/db";
import { articleLikes } from "@/db/schema";
import { count, eq } from "drizzle-orm";

export async function getLikeCount(articleSlug: string) {
  const result = await db
    .select({ count: count() })
    .from(articleLikes)
    .where(eq(articleLikes.articleSlug, articleSlug));

  return result[0]?.count ?? 0;
}

ユーザーがいいねした記事一覧

import { db } from "@/db";
import { articleLikes } from "@/db/schema";
import { eq, desc } from "drizzle-orm";

export async function getUserLikes(userId: string) {
  return db
    .select({
      articleSlug: articleLikes.articleSlug,
      createdAt: articleLikes.createdAt,
    })
    .from(articleLikes)
    .where(eq(articleLikes.userId, userId))
    .orderBy(desc(articleLikes.createdAt));
}

記事削除時の対応

MDXファイルを削除した場合、対応するいいねデータをどう扱うかは運用ポリシーによります。

選択肢1: 孤児データを許容

MDXファイル削除後もいいねデータを保持します。記事が復活した場合にいいね数を維持できます。

選択肢2: 定期的なクリーンアップ

存在しないarticle_slugを持ついいねを定期的に削除します:

import { getArticleSlugs } from "@/lib/articles"; // fumadocsから全slugを取得
import { db } from "@/db";
import { articleLikes } from "@/db/schema";
import { notInArray } from "drizzle-orm";

export async function cleanupOrphanLikes() {
  const validSlugs = await getArticleSlugs();

  await db
    .delete(articleLikes)
    .where(notInArray(articleLikes.articleSlug, validSlugs));
}

まとめ

MDXベースの記事に対するいいね機能では、以下の設計が推奨されます:

  1. 記事テーブルは不要: MDXのslugを直接参照することで、シンプルかつメンテナンスしやすい設計に
  2. サロゲートキー + ユニーク制約: Better Authの設計パターンと一致させ、API設計をシンプルに
  3. 適切なインデックス: 想定されるクエリパターンに基づいたインデックス設計
  4. ON DELETE CASCADE: ユーザー削除時のデータ整合性を自動的に保証

この設計により、fumadocsとBetter Auth、Turso、Drizzleを組み合わせた環境で、シンプルかつ拡張性のあるいいね機能を実装できます。