3 行まとめ
- 学校向け教育プラットフォームを Next.js 16 + Supabase Pro + Clerk + Vercel Pro で構築し、本番運用中
- 40 名同時アクセス対応 ・RAG セマンティック検索 (pgvector + Gemini Embeddings) ・PWA キャッシュ戦略 ・多テナント (組織分離 + ロール制御) を全部入り
- 1 人で 3 週間で立ち上げ → 本番運用フェーズで Clerk OAuth レート制限 ・Service Worker キャッシュ汚染 など現場ハマりも経験
想定読者
- 初級: Next.js を触り始めた人 / 教育系 SaaS を作ろうとしている人
- 中級: 認証付きアプリの多テナント設計を検討中の人 / pgvector で RAG をやりたい人
- 上級: 本番で 40-100 同時接続を捌くための設計判断を知りたい人 / 観測基盤を一人で設計している人
教育ドメインでの実装記録ですが、ドメイン非依存のアーキテクチャ知見として書きます。
システム全体像
┌──────────────────────────────────┐
│ Clerk (Auth + OAuth) │
└──────────────────────────────────┘
│
┌─────────────────────────┴─────────────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ Vercel Pro │ │ Supabase Pro │
│ (hnd1 / Tokyo) │◄────────Prisma ORM──────►│ Postgres + pgvector │
│ │ │ + RLS │
│ Next.js 16 App │ └──────────────────────┘
│ Edge Network │ ▲
│ + Service Worker│ │
└──────────────────┘ ┌──────────────────────┐
│ │ Gemini API │
│ fire-and-forget │ text-embedding-004 │
└────── after() ────────────────────► │ (768 dim vectors) │
└──────────────────────┘
技術スタック一覧
| レイヤ | 採用技術 | 理由 |
|---|---|---|
| Frontend | Next.js 16 (App Router) + React 19 | Server Components / streaming / Edge ready |
| Styling | Tailwind 4 + framer-motion + lucide-react | CSS-in-JS なし、軽量・型安全 |
| Auth | Clerk | Google OAuth + email、組織機能、ユーザー管理 UI 込み |
| DB | Supabase Postgres (Pro / Tokyo / Shared Pooler) | RLS + pgvector + Realtime |
| ORM | Prisma 5 | 型生成 + migration が安定 |
| Embeddings | @google/generative-ai (Gemini text-embedding-004) | LangChain 依存なし、768 次元 |
| Hosting | Vercel Pro (hnd1 / Tokyo region) | Edge Network + ISR + Cron |
| PWA | Custom Service Worker | network-first HTML + cache-first 静的 |
| 監視 | AuditLog テーブル + Vercel Analytics | 自前 + 標準 |
| CI/CD | GitHub Actions + Vercel 自動デプロイ | TypeScript strict + npm run build |
1. 多テナント設計 — Clerk + 組織 + ロール 3 階層
複数組織が同じシステムを使い、お互いのデータが見えないようにする多テナント設計です。
構造
Organization (校・社単位)
├── members: UserProfile[]
├── inviteCode: 「sapporo-nursing-2026」
└── roleLabels: JSON ({teacher: "先生", student: "生徒"})
UserProfile (Clerk userId と 1:1 紐付け)
├── role: "admin" | "teacher" | "student"
└── organizationId: 所属組織 FK
アクセス制御の 3 ステップ
// 1. Clerk が認証 → userId 取得
const { userId } = await auth();
// 2. UserProfile から role + organizationId 取得
const profile = await prisma.userProfile.findUnique({
where: { clerkUserId: userId },
select: { role: true, organizationId: true },
});
// 3. データ取得時、組織でフィルタ
const orgMembers = await prisma.userProfile.findMany({
where: { organizationId: profile.organizationId },
select: { clerkUserId: true, boardAnonymous: true, boardNickname: true },
});
const allowedUserIds = new Set(orgMembers.map(m => m.clerkUserId));
// 4. 投稿取得は組織メンバーのもののみ
const posts = await prisma.discussionPost.findMany({
where: {
parentId: null,
clerkUserId: { in: [...allowedUserIds] },
},
});
招待リンク自動入会
/members/join/s-2026 のような URL で、inviteCode 経由で組織に自動紐付け。学生は メール登録するだけで 適切な組織 + role が振られる仕組み。
教師の「プレビュー」モード
教師アカウントから「生徒視点で見る」プレビュー機能を実装。これは生徒のテストアカウントとは別で、実生徒データに影響せずに動作確認 できる。本番運用前のレビューや、生徒からの問い合わせ対応で効きます。
2. 掲示板 — 匿名化 + 返信ツリー
返信機能 (X 風)
スキーマに self-relation を追加:
model DiscussionPost {
id String @id @default(cuid())
parentId String?
parent DiscussionPost? @relation("PostReplies", fields: [parentId], references: [id], onDelete: SetNull)
replies DiscussionPost[] @relation("PostReplies")
sectionIndex Int?
// ...
@@index([parentId])
@@index([lessonNo, sectionIndex])
}
GET API で parentId: null の親投稿に include: { replies: ... } で返信を入れ子取得。深さは 1 で固定 (UI 上も返信に返信はできない)。
3. RAG セマンティック検索 — pgvector + Gemini Embeddings (LangChain なし)
レッスン横断で「○○について書いた投稿を探す」機能。LangChain を入れずに、@google/generative-ai + Supabase pgvector で実装。
Schema
model DiscussionPostEmbedding {
id String @id @default(cuid())
postId String @unique
contentSnapshot String @db.Text
embedding Unsupported("vector(768)")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Unsupported("vector(768)") で型システムから外し、INSERT/SELECT は $executeRawUnsafe / $queryRawUnsafe で行う。
Embedding 生成 (fire-and-forget + after())
投稿のレスポンスを早く返したいので、embedding 生成は非同期で:
import { after } from "next/server";
// 投稿作成
const post = await prisma.discussionPost.create({ data });
// レスポンス送信後に embedding を生成 (Serverless 安全)
after(() =>
upsertPostEmbedding(post.id, post.originalText)
.catch(e => console.error("[embedding] failed", e))
);
return NextResponse.json({ post });
Next.js 16 の after() を使うことで、Serverless function がレスポンス返した後も継続処理が保証される。これを使わずに Promise.then() だけだと Vercel Lambda が freeze される可能性がある。
Cosine 類似度検索
const vector = await generateEmbedding(query);
const vectorLiteral = `[${vector.join(",")}]`;
const rows = await prisma.$queryRawUnsafe<Row[]>(
`SELECT e."postId" as "postId", (e."embedding" <=> $1::vector) as "distance"
FROM "DiscussionPostEmbedding" e
INNER JOIN "DiscussionPost" p ON p."id" = e."postId"
LEFT JOIN "UserProfile" up ON up."clerkUserId" = p."clerkUserId"
WHERE p."parentId" IS NULL
AND ${orgFilterSQL}
ORDER BY e."embedding" <=> $1::vector ASC
LIMIT $2`,
vectorLiteral,
limit
);
<=> は pgvector のコサイン距離演算子。SQL レベルで組織分離 を強制することで、Application 層のバグでもデータ漏洩しない設計。
Backfill スクリプト
既存投稿に対する embedding 一括生成は TypeScript スクリプトで:
npx tsx scripts/backfill-board-embeddings.ts
-
prisma.discussionPostEmbedding.findUniqueで既処理スキップ - 500ms sleep でレート制限回避
- 進捗ログ + 失敗集計を出力
4. 過去問データベース統合 — 別 Supabase + Python スクリプト
試験など、別の Supabase プロジェクトに格納された数千件の過去問データベースとレッスンを連携。
アーキテクチャ
Lessons プロジェクト (TypeScript) ExamQuestion プロジェクト (Python メンテ)
├─ Web で配信 ├─ 関連過去問の整理・タグ付け
├─ レッスン本文に過去問引用 ├─ 検索スクリプト
└─ クイズ └─ 詳細取得スクリプト
│ ▲
└──────── 引用時に手動同期 ────────────────┘
検索ワークフロー
# キーワード検索
python3 scripts/search-exam-questions.py "個人情報保護" "要配慮"
# 詳細取得
python3 scripts/fetch-exam-details.py 2024_38_CE_AM_62
Python + psycopg2 で SUPABASE_DIRECT_URL 経由で直接クエリ。pgbouncer=true の URL parameter は psycopg2 が受け付けないので、URL から ? 以降を strip して接続:
url = re.sub(r'\?.*$', '', url)
conn = psycopg2.connect(url)
検索結果から手動で lessons.ts に過去問を引用 (出典 + 問題番号併記)。重複回避のため、引用済み問題 ID は memory に記録するルールにしている。
5. PWA + Service Worker キャッシュ戦略
オフラインでも一部機能が使えるよう PWA 化。
キャッシュ戦略
| リソース | 戦略 | キャッシュ | 理由 |
|---|---|---|---|
HTML (/members/*, /) |
network-first + cache fallback | te-exam-html-vX |
認証データの混線を避ける |
/_next/static/* |
cache-first | te-exam-static-vX |
静的アセットは hash 付きで安全 |
/icons/*, /manifest.json
|
cache-first | static | 変わらない |
/api/exam/*, /api/admin/*
|
network-only (キャッシュなし) | — | 認証ヘッダー差で漏洩リスク |
/api/image-proxy |
stale-while-revalidate | te-exam-images-vX |
速度優先 + 背景で更新 |
バージョン bump で旧キャッシュ無効化
const CACHE_VERSION = "v3-2026-05-26-report-button";
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k.startsWith("te-exam-") && !k.endsWith(CACHE_VERSION))
.map((k) => caches.delete(k))
);
await self.clients.claim();
})());
});
授業中に「画面が真っ黒で動かない」と訴える学生が複数発生 → 古い SW が古い JS を引いていた ことが原因。CACHE_VERSION を bump して再デプロイすることで、次回リロード時に新 SW が activate → 旧キャッシュ全削除で復旧。
これ以降、毎リリース前に CACHE_VERSION を bump するチェックリスト をビルド前 lint に組み込んだ。
6. 40 名同時アクセス対応 — Pro 構成 + Fast Path
インフラ Pro 化の判断
最初は無料枠で始めたが、20 人同時ログインの段階で Vercel Function timeout / Clerk API レート制限が発生。
| Tier | 月額 | 解決した問題 |
|---|---|---|
| Vercel Pro | $20 | Function timeout 拡張・並列実行枠 |
| Supabase Pro | $25 | Tokyo region + Shared Pooler + バックアップ |
| Clerk Free | $0 | OAuth 同時数の限界が見えた (後述 §10) |
Tokyo region に揃える効果
すべてのコンポーネントを Tokyo (hnd1 / ap-northeast-1) に集約することで、東京の学生からのレイテンシが 200ms → 60ms 程度に改善。Web Vitals の LCP も大きく改善。
Fast Path で Clerk API 呼び出しを抑制
/api/user-profile GET は授業中に何度も呼ばれるが、毎回 Clerk currentUser() を叩くと外部 API 制限に当たる:
// Slow Path: Clerk API 呼び出し + 組織紐付けロジック (新規ユーザー用)
// Fast Path: DB の UserProfile だけで即返す (確定済みユーザー用)
const fastProfile = await prisma.userProfile.findUnique({
where: { clerkUserId: userId },
include: { organization: { select: { id: true, name: true, domain: true } } },
});
if (fastProfile?.onboardingCompleted && fastProfile?.organizationId) {
// 副作用は after() で送信後に実行
after(async () => {
try { await ensureProjectAccesses(userId, fastProfile.role, fastProfile.organizationId); }
catch {}
});
return NextResponse.json({ profile: fastProfile });
}
// 以下 Slow Path...
これで授業中の Clerk API 呼び出しが大幅に減り、レート制限の余裕が生まれた。
5 分間隔の Health Cron でウォームアップ
vercel.json で /api/health を 5 分ごとに叩いて、cold start を避ける:
{
"crons": [{ "path": "/api/health", "schedule": "*/5 * * * *" }]
}
7. 観測 + 教師ダッシュボード
AuditLog で何でも記録
専用テーブルを増やさず、AuditLog テーブルに action と resource で型を分けて記録:
await prisma.auditLog.create({
data: {
clerkUserId: userId,
action: "lesson_exercise", // ← イベント種別
resource: `lesson-${lessonNo}:${jsonPayload}`, // ← データ
ipAddress: timestampISO, // ← 流用フィールド
userAgent: notesContent, // ← 流用フィールド (最大 5000 字)
},
});
スキーマ拡張せずに、既存フィールドを type-tagged に使い回す簡易設計。プロトタイプ段階では正解だが、データ量が増えたら専用テーブルに分けるべき (移行スクリプトは別途用意済み)。
クイズタイミング保存
// 開始時
const startedAt = new Date().toISOString();
fetch("/api/lesson-log", {
method: "POST",
body: JSON.stringify({
lessonNo,
promptText: `quiz_start: at=${startedAt}`,
aiResponse: JSON.stringify({ startedAt, totalQuestions }),
}),
});
// 提出時
fetch("/api/lesson-log", {
method: "POST",
body: JSON.stringify({
lessonNo,
promptText: `quiz_submit: score=${score}/${total} duration=${durationSec}s`,
aiResponse: JSON.stringify({ startedAt, submittedAt, durationSec, score, answers }),
}),
});
教師ビューでは start / end / duration を全部表示。「ちゃんと考えて答えたか」「カンニングで一瞬で終わってないか」が見える。
8. クラス全体傾向の集計 + プライバシー
クラスのプロフィール (使った AI / PC 環境 / 興味分野) を 匿名集計 で表示する ClassProfile コンポーネント。
5 人未満は表示しない
const MIN_STUDENTS = 5;
if (totalStudents < MIN_STUDENTS) {
return NextResponse.json({
privacyOk: false,
message: `プライバシー保護のため ${MIN_STUDENTS} 人以上で表示されます`,
});
}
少人数だと匿名でも識別されうるので、表示自体を停止。
Role 別フィールドフィルタ
教師には進路希望データを返すが、生徒のレスポンスからは完全削除:
return NextResponse.json({
totalStudents,
attributes: {
aiTools: ...,
pcStatus: ...,
// 進路希望は教師・管理者のみ
...(isStaff ? { careerPath: buildArrayCounts(...) } : {}),
},
});
生徒に返るレスポンスにそもそも careerPath フィールドが存在しない = ネットワーク経由でも見えない。UI 側で if (isStaff) だけだとブラウザ DevTools から見えてしまうので、API レベルで切るのが正解。
9. 本番でハマった話
9.1 Clerk OAuth 同時レート制限
授業開始 5 分で 20 人ログイン成功・20 人がエラー画面。原因は Clerk Free プランの Google OAuth 同時ハンドリング上限。
緊急対応: 学生に「Google ボタン使わず、Email + Password でサインアップ」と指示 → 復旧。
恒久対策:
-
/login?org=nでメール優先 UI に切り替え予定 - Clerk Pro 課金検討中
9.2 Service Worker キャッシュ汚染
20 人ログイン成功後、さらに「画面真っ黒」勢が発生。原因は 古い SW が古い JS をキャッシュ していたこと。
緊急対応:
-
CACHE_VERSIONを bump して 1 分以内にデプロイ → 新 SW 配信
恒久対策:
- リリース前チェックリストに
CACHE_VERSIONbump 確認を追加 - SW にバージョン情報を
/api/versionから取得して 古いと自動更新 する仕組みを検討中
9.3 Supabase pgvector のクエリパラメータ問題
SUPABASE_DATABASE_URL には ?pgbouncer=true&connection_limit=1 が付いているが、psycopg2 は受け付けない:
url = re.sub(r'\?.*$', '', url) # 強制 strip
conn = psycopg2.connect(url)
これ系の罠は事前に分からないので、localで実行して挙動を確認するスクリプトを 1 本書いておく のが効率的。
9.4 Migration のローカル実行不可
db.pdlxtukeakiabjxqkhkf.supabase.co:5432 の DIRECT URL がローカルから到達できない (ファイアウォール?)。
対応:
-
npx prisma migrate devの代わりに、手動で migration.sql を作成 - 本番 Supabase の SQL Editor で実行
-
prisma migrate resolve --appliedで記録
これも初手だと混乱しがちなので、prisma/migrations/README.md に手順を明文化。
10. CI/CD + リリースフロー
- GitHub Actions で
npx tsc --noEmit+npm run buildを PR ガード - main へマージで Vercel 自動デプロイ
- 1 リリース = 1 PR 原則
- migration を含む変更は Supabase SQL Editor で先に流す → コードデプロイ の順
- リリース前チェック:
bash scripts/pre-class-smoke-test.shで:- tsc / lint / build
- 主要ルートの 200 確認
- レスポンスタイム計測
- Vercel 本番デプロイ確認
- 重要ファイル存在確認
11. キャリア視点 — この案件で証明したこと
1 人で 3 週間 → 本番運用 → 改善サイクル までやり切ったので、以下の能力をフル稼働した:
| カテゴリ | やったこと |
|---|---|
| アーキテクチャ設計 | 多テナント・ロール・組織分離を最小コードで実装 |
| パフォーマンス | 40 名同時アクセスを Tokyo region + Pooler + Fast Path で捌く |
| AI 連携 | LangChain なしで pgvector + Gemini Embeddings で RAG を実装 |
| データプライバシー | 匿名集計・5人未満非表示・role 別フィールドフィルタ |
| インシデント対応 | 本番ハマりを 5 分以内に復旧 + 恒久対策をコード化 |
| 観測 | AuditLog でイベント駆動の分析基盤を最小コードで |
| PWA | Service Worker のキャッシュ戦略を本番運用してハマる →学ぶ |
| ドメイン理解 | エンドユーザー (教師・学生) の視点で UI・運用フローを設計 |
「フルスタック+運用+ドメイン」を 1 人で回せる、というのを実装で示せた案件です。
おわりに
教育プラットフォームというドメイン特化のシステムですが、構築過程で得られた知見は 汎用的なフルスタック技術 です。pgvector + Gemini で RAG をやりたい人、多テナント設計を検討中の人、Service Worker でハマっている人の参考になれば。
質問・指摘は X (@endoh_taichi) か Qiita コメントへ。