使用技術

Errata Hub を構成する技術と、実際の組み方をまとめています。

技術スタック

フレームワーク
Next.js 16(App Router)/ React 19
言語
TypeScript
スタイリング
Tailwind CSS v4
認証
Supabase Auth(PKCE)/ ソーシャルログイン(Google・GitHub)
データベース
PostgreSQL(Supabase)/ Prisma v7
ファイル保存
Supabase Storage
バリデーション
Zod
外部 API
OpenBD / Google Books
テスト
Vitest / Testing Library / Playwright
CI
GitHub Actions / CodeQL / Dependabot
ホスティング
Vercel

ディレクトリ構成

機能ごとに縦に切り(features/)、利用者の操作(Server Action)も 1つ1ファイルで、その機能の actions/ に置いています。 複数の機能にまたがる操作だけは、画面と同じく app/ で組み立てます。 DB を読み書きしてよいのは各機能の db/ と、機能をまたぐ処理(services/)だけで、画面や操作のコードから DB の クライアントを読み込もうとすると lint エラーで落ちます。

src/
├── app/                  画面と HTTP リクエストの受け付け口(URL がそのままフォルダ)。
│                         複数の機能にまたがる操作も、使う画面の隣に置く
│   ├── (site)/             公開ページ
│   ├── admin/              管理画面
│   └── api/                Route Handler(HTTP で直接受ける必要がある4本だけ)
├── features/             機能ごとのまとまり(下は「投稿」の例)
│   └── report/
│       ├── actions/        この機能だけで完結する操作。1つ1ファイルの Server Action(フォームの送信先をサーバー上の関数に直接向ける仕組み)
│       ├── db/             DB を読み書きする。ここと services 以外からは DB を操作しない
│       ├── components/     この機能でしか使わない画面部品
│       ├── constants/      ラベル・文字数上限
│       ├── utils/          DB にも画面にも依存しない関数
│       └── schema.ts       入力の検査(Zod)
├── components/           機能に属さない画面部品(ui = 部品 / layout = 枠)
├── services/             機能をまたぐ処理(認可・操作ログ・レート制限)
├── lib/                  外部サービスへ接続するクライアントを作る(Prisma・Supabase)
├── utils/ constants/     どの機能にも依存しない純粋な関数・定数
└── proxy.ts              静的アセットを除く全リクエストが最初に通る(CSP の nonce 発行・認証の確認)

実装の要点

OpenBD / Google Books API

ISBN で本を引くときは OpenBD、タイトルで探すときと書影(サムネイル)が要るときは Google Books を使う。どちらもブラウザからは呼ばず、サーバー側の /api/books/openbd と /api/books/search を通す。Google Books の API キーをブラウザに置かないためと、閲覧者の IP を外部サービスへ渡さないため。

Vitest / Playwright

種別やステータスの分岐は単体テストで押さえ、ログイン・投稿・管理操作の一連は実ブラウザで通している。文字のコントラスト比や CSP 違反の有無も e2e で測っていて、見た目の性質を目視に任せていない。

セキュリティと運用

CSP(nonce + strict-dynamic)

ページを返すたびに1回限りの識別子(nonce)を発行し、その識別子が付いたスクリプトだけをブラウザに実行させる。外部から差し込まれたスクリプトは識別子を持たないため、HTML の中にあっても実行されない。引き換えに、ページを事前に生成して配信することはできない(識別子がリクエストごとに変わるため)。

レート制限(Postgres)

投稿・追記・画像アップロード・書籍検索・出版社の回答・賛同には、利用者ごとに一定時間あたりの回数制限をかけている。回数の記録に専用のサービスは足さず、すでに使っている PostgreSQL の1テーブルで数える。加算と上限の判定を1つの SQL 文で行うので、同時に複数のリクエストが来ても二重に数えられない。

認可はサーバー側で判定

誰が何をしてよいか(認可)は、管理操作を実行するたびにサーバーでロールを確認して決めている。画面にボタンを出すかどうかは補助でしかなく、権限を保証していない。データベース側でも、ブラウザから直接つなぐ経路は全て拒否している(アプリのサーバーを通らない読み書きを成立させないため)。

操作ログ(監査ログ)

投稿・権限への変更と退会は、削除のような取り消せないものだけでなく、更新も含めて記録している(表示名とリンクの変更は記録していない)。変更前と変更後の内容を添えるので、誰が何をどう変えたかを後から辿れる。記録と操作は1つのトランザクションにまとめてあり、どちらか片方だけが残ることはない(外部サービスをまたぐ退会だけは例外で、記録に失敗しても退会は成立させる)。

システム構成

ブラウザ
ユーザー
↓
Vercel
Next.js 16(サーバーコンポーネント / Route Handler)
↓
Supabase
Auth + Postgres + Storage
OpenBD / Google Books
書誌データ・書影

ブラウザからのリクエストは Vercel 上の Next.js が受け、サーバーコンポーネントや Server Action が Supabase(認証・データ永続化・画像の保存)と書誌 API に問い合わせます。 認証は PKCE の code フロー、外部 API キーはサーバー側に隠蔽しています。 書誌 API へはブラウザから直接アクセスせず、サーバーが代理で取得します(閲覧者の IP を外部へ渡さないため)。

データモデル

1:N1:N1:N1:N1:N1:N出版社Publisher書籍Book投稿Report利用者Profile出版社の回答PublisherComment追記ReportAddendum

図は横にスクロールできます。

主要な6つのテーブルだけを描いています。矢印は1対多の関連で、矢の先が「多」側です。 投稿は利用者と書籍のどちらにも必ず紐づき(どちらも必須の参照)、書籍には出版社が任意で付きます。 出版社の回答が投稿と出版社の両方を参照しているのは、書籍の出版社を管理者が後から直せるためです。 書籍から辿る形にすると、過去の発言の帰属が後から変わってしまいます。 追記と回答はどちらも作った時点で不変で、2回目が1回目を書き換えることはありません。

画像・賛同・操作ログ・レート制限のテーブルは、関連の形を見るのに要らないので省きました。 全11テーブルの図(自動生成)と、型・制約・その判断をした理由はリポジトリにあります。schema.prismaER 図(全テーブル)

それぞれの選定でどんな代償を受け入れたか、なぜ採らなかった選択肢があるかは、 リポジトリの README と設計ドキュメントに書いています。 色や UI 部品の決まりごとはデザインシステムにまとめています。GitHub でソースを見る