2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

教育プラットフォームを Next.js 16 + Supabase Pro で本番運用している話 — 40 名同時アクセス対応 / RAG / PWA / 多テナント

2
Posted at

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_VERSION bump 確認を追加
  • 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 コメントへ。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?