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-guidecontent/articles/t3-env-introduction-guide.mdx→ slug:t3-env-introduction-guide
このslugは以下の理由から、記事の一意識別子として十分に機能します:
- ファイルシステムによる一意性保証: 同じディレクトリ内に同名ファイルは存在できない
- URLとの直接対応: slugがそのままURLパスになる
- 変更の追跡可能性: Gitで管理されるため、変更履歴が残る
- シンプルな運用: 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つの役割を果たします:
- 一意性制約: 同じユーザーが同じ記事に複数回いいねすることを防止
- クエリ最適化: ユーザー×記事の組み合わせ検索を高速化
クエリ例
いいね追加
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ベースの記事に対するいいね機能では、以下の設計が推奨されます:
- 記事テーブルは不要: MDXのslugを直接参照することで、シンプルかつメンテナンスしやすい設計に
- サロゲートキー + ユニーク制約: Better Authの設計パターンと一致させ、API設計をシンプルに
- 適切なインデックス: 想定されるクエリパターンに基づいたインデックス設計
- ON DELETE CASCADE: ユーザー削除時のデータ整合性を自動的に保証
この設計により、fumadocsとBetter Auth、Turso、Drizzleを組み合わせた環境で、シンプルかつ拡張性のあるいいね機能を実装できます。