テーマ
技術スタック
フレームワーク・ランタイム
- Next.js 15+ (App Router) — SSR/SSG/ISR、Server Components、Route Handlers を活用
- React 19 — Server Components + Client Components の使い分け前提
- TypeScript 5+ —
strict: true、noUncheckedIndexedAccess: true推奨 - Node.js 20 LTS 以上
スタイリング
- Tailwind CSS v4 — Figma Make のデモが v4 を採用しているため踏襲
- CSS変数によるデザイントークン —
--gold,--gold-light,--gold-dark,--rose-gold,--ivory,--charcoal,--font-display,--font-bodyをglobals.cssの:rootで定義 - shadcn/ui — Radix UI ベースの UI コンポーネント群(Dialog, Button, Slider 等)。Figma Make 出力の
components/ui/*をそのままcomponents/ui/に移植 - Webフォント —
next/font/google経由でCormorant Garamond(display)とJost(body)を読み込む
主要ライブラリ
| 用途 | ライブラリ | 備考 |
|---|---|---|
| アニメーション | motion (旧 framer-motion) | 12.x 系 |
| アイコン | lucide-react | 0.487.0+ |
| 画像スライダー | react-slick + slick-carousel | 候補者詳細のメインスライダー。SSRに注意("use client" 必須) |
| Confetti演出 | motion の <motion.div> ループで実装(デモと同様) | 軽量なため canvas-confetti を使わず代替可 |
| フォーム | react-hook-form + zod | 決済・問い合わせ系フォームで使用 |
| ユーティリティ | clsx, tailwind-merge, class-variance-authority | shadcn/ui 標準セット |
| ORM | @prisma/client, prisma | DB アクセス |
| CSV読み込み | csv-parse | Seedスクリプトで使用 |
ルーティング (App Router)
Figma Make のデモは react-router を使用しているが、Next.js 移植時はファイルベースルーティングに置き換える。
app/
layout.tsx # 全体レイアウト(背景・装飾・Provider)
page.tsx # Home(候補者一覧)
candidate/
[id]/
page.tsx # 候補者詳細
ranking/
page.tsx # ランキング状態管理
- クライアント状態: React Context API(
AuthContext,VotingContext)をapp/providers.tsxに集約 - サーバー状態: 将来 API 連携時は TanStack Query または Server Actions を採用
- 永続化(将来):
useStateの代わりにlocalStorageラッパー or サーバーDB
データレイヤー
- 現行: PostgreSQL + Prisma に移行済み(下記「本番段階」の構成で稼働中)
- スキーマ:
prisma/schema.prisma(User,Candidate,CandidateImage,Vote,Purchase等) - Seed:
prisma/seed.ts、CSV+画像からの一括投入はscripts/seed-candidates.ts - ローカルDB:
docker-compose.ymlでpostgres:17を起動(mwj/mwj_dev/miss_world_japan)
- スキーマ:
- 本番段階:
- DB: PostgreSQL 15+(全データを集約)
- ORM: Prisma — スキーマ駆動でマイグレーションも一元管理
- マネージドDB: Neon を採用(Branching機能を活用しPRごとに独立した環境を提供)
- 画像保存: PostgreSQL の
bytea型でDB内にBLOBデータとして保存(候補者画像は CandidateImage テーブル) - 管理画面: 自前実装(
/admin配下)。候補者・画像のCRUDを提供 - 初期データ投入: CSV + 画像ファイルから Seedスクリプトで一括投入
- 投票数の集計は DB クエリで実施し、必要に応じて集計結果をキャッシュ
- データアクセスは
Server Actionsまたは Route Handler 経由で実施 - 楽観的更新(
useOptimistic)でUI反応性を高める
画像配信の方針
候補者画像はDB内BLOBとして保持し、専用エンドポイント(/api/images/[id])経由で配信する。
| 観点 | 仕様 |
|---|---|
| 配信エンドポイント | GET /api/images/[id] |
| キャッシュヘッダー | Cache-Control: public, max-age=31536000, immutable |
| キャッシュバスト | URLパラメータ ?v={updatedAt} で画像更新時に自動更新 |
| next/image との連携 | 同一ドメインなので remotePatterns 設定不要、自動最適化が機能する |
| 認証 | 公開エンドポイント(URLはUUIDで推測困難) |
CDN/ブラウザキャッシュが効くため、初回以外は通常の外部ストレージ配信と同等のパフォーマンスとなる。
データモデルの要件・テーブル設計は specs/data-model/ を参照。 管理画面の要件・設計は specs/admin/ を参照。
認証
- 現行: NextAuth.js (Auth.js) v5 で実装済み
- 設定: ルート直下の
auth.ts(認証ハンドラ + 設定をエクスポート) - Route Handler:
app/api/auth/[...nextauth]/route.ts - 管理画面ログイン:
app/admin/login/page.tsx(Credentials Provider 想定) - パスワードハッシュ:
bcrypt
- 設定: ルート直下の
- 本番: 上記に加え Facebook Provider + Instagram Provider を追加予定
- セッションは JWT or DB セッション
middleware.tsで投票エンドポイント保護
決済
- デモ:
StripePaymentModalでカード番号入力 → setTimeout で成功演出 - 本番: Stripe Checkout (Hosted) または Stripe Elements + Payment Intents
- Webhook で支払い確認 → 投票券(クレジット)を加算
ディレクトリ構成
app/
(routes)/
page.tsx
candidate/[id]/page.tsx
ranking/page.tsx
api/
vote/route.ts # 将来: 投票エンドポイント
checkout/route.ts # 将来: Stripe決済セッション
layout.tsx
globals.css
providers.tsx
components/
ui/ # shadcn/ui (Button, Dialog, etc.)
layout/ # Header, Footer, ScrollToTop
candidate/ # CandidateCard, CandidateGrid, ImageSlider
modals/ # SocialLoginModal, PurchaseCreditsModal, StripePaymentModal
voter/ # VoterRanking
lib/
contexts/ # AuthContext, VotingContext
data/ # candidates.ts, voters.ts (デモ用)
utils/ # cn(), date helpers
types/ # Candidate, Voter, User 型定義
public/
fonts/, images/コーディング規約
- Server Components がデフォルト。インタラクションが必要な場合のみ
"use client"を付与 - クライアントコンポーネントは
components/配下にまとめ、ファイル先頭に"use client"を明示 - 画像は
next/imageを使用(candidate.imageなどの外部URLはnext.config.tsのimages.remotePatternsで許可) - リンクは
next/linkの<Link href="...">を使用 - CSS変数を直接使う場合は
style={{ color: 'var(--gold)' }}のままで可(デモのスタイルを踏襲) - 環境変数は
.env.localに配置し、クライアント参照にはNEXT_PUBLIC_プレフィックスを付ける
デプロイ
- Vercel を第一候補(Next.js最適化)
- 環境変数:
NEXTAUTH_SECRET,STRIPE_SECRET_KEY,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,FACEBOOK_CLIENT_ID/SECRET,INSTAGRAM_*など
パフォーマンス・最適化(全画面共通)
各画面の requirements.md で個別に記載しないでよい、サイト全体の共通方針を定める。
Core Web Vitals 目標
- LCP: 2.5秒以内
- INP: 200ms以内
- CLS: 0.1以下
レンダリング戦略
- Server Components がデフォルト。クライアント側のインタラクションが必要な部分のみ
"use client"で切り出す - 静的生成(SSG)を優先: 候補者一覧・候補者詳細のように更新頻度が低いページは
generateStaticParams()で全パターンを事前生成する - ISR: 動的に変わるデータは
revalidateオプションで再生成する(後述のキャッシュポリシー参照) - 動的データ(投票後のリアルタイム表示)はクライアント側で楽観的更新する
キャッシュポリシー(全画面共通)
DB クエリの結果と画像配信エンドポイントの応答を、Next.js のキャッシュ機構と CDN で保持し、ほぼすべてのリクエストを高速応答させる。
採用するキャッシュ層
| 層 | 用途 |
|---|---|
| Vercel CDN(Edge Network) | 静的HTML・画像配信エンドポイント・最適化済み画像のエッジキャッシュ |
Next.js Data Cache (unstable_cache) | DBクエリ結果のサーバー側キャッシュ |
| Next.js Image Cache | next/image の最適化結果(リサイズ・WebP変換後)のキャッシュ |
| ブラウザキャッシュ | 画像本体・静的アセット(フォント、JS、CSS) |
unstable_cache の挙動
DBクエリ結果をタグ付きでキャッシュする。
ts
import { unstable_cache } from 'next/cache';
export const getCandidates = unstable_cache(
async () => prisma.candidate.findMany({ /* ... */ }),
['candidates'], // キャッシュキー
{ revalidate: 600, tags: ['candidates'] }, // 10分キャッシュ + タグ付け
);キャッシュが更新されるタイミング:
revalidateの時間経過後の最初のアクセス — そのリクエストには古い値が即座に返り、裏で再生成される(stale-while-revalidate)revalidateTag('tagName')の明示的呼び出し — 管理画面での編集後など任意のタイミングで即時無効化revalidatePath('/path')の呼び出し — ページ単位で無効化- デプロイ — 新ビルドで完全クリア
- キャッシュ容量超過 — LRU方式で古いエントリから破棄
データ別キャッシュ設定
| データ種別 | revalidate | タグ | 無効化トリガー |
|---|---|---|---|
| 候補者の表示用情報 | 600秒(10分) | candidates | 管理画面での編集時に revalidateTag('candidates') |
| 投票券パッケージ一覧 | 3600秒(1時間) | credit-packages | 運営による変更時に手動 revalidateTag |
| 候補者ごとの得票数集計 | 30秒 | vote-counts | revalidate時間経過のみ |
| 応援者貢献度ランキング | 60秒 | voter-rankings | revalidate時間経過のみ |
| ユーザー固有データ | キャッシュしない | – | – |
画像配信のキャッシュ設定
候補者画像は /api/images/[id] 経由で配信する。Route Handler のレスポンスヘッダーで CDN/ブラウザキャッシュを最大限活用する。
| 設定 | 値 | 効果 |
|---|---|---|
Cache-Control | public, max-age=31536000, immutable | 1年間キャッシュ、ブラウザは絶対に再リクエストしない |
ETag | "{imageId}-{updatedAt}" | 内容識別 |
| URL設計 | ?v={updatedAt} をクエリで付与 | 画像更新時にURLが変わり、新しいキャッシュエントリを生成 |
これにより、初回アクセス以降はDB に到達せず、CDN/ブラウザキャッシュから即時応答する。実質的に外部ストレージ配信と同等のパフォーマンス。
設計原則
- 編集頻度が低いデータほど revalidate を長く: 候補者情報は10分(編集時に
revalidateTagで即時無効化可能) - 編集頻度が高いデータは revalidate を短く: 投票数は30秒程度で擬似的なリアルタイム感を出す
- ユーザー固有のデータはキャッシュしない: ログイン状態・自分の投票履歴など、ユーザーごとに異なる値はリクエストごとに取得
- 更新時にタグ無効化できるものは長めに設定して問題ない: 編集者が反映を待つことなく、ユーザーは常に新しい値を見られる
- 画像はURLパラメータでキャッシュバスト: ファイル更新時は
?v=の値が変わるためURLが変わり、新しいキャッシュエントリが作られる
キャッシュ無効化のフロー
運営者が管理画面で候補者編集
↓
Server Action で DB更新
↓
revalidateTag('candidates')
↓
次のアクセスで DB再取得 → 新しい値でキャッシュ運営者が画像差し替え
↓
CandidateImage の updatedAt が更新される
↓
画像URL `?v={updatedAt}` の値が変わる
↓
ブラウザは新しいURLとして再取得 → 新しいキャッシュエントリ画像最適化
next/imageを必須使用。生の<img>タグは禁止
画像最適化
next/imageを必須使用。生の<img>タグは禁止- 候補者画像は DB BLOB を
/api/images/[id]経由で配信(同一ドメインなのでremotePatterns設定不要) - 画像更新時のキャッシュバストは URLクエリ
?v={updatedAt}で対応 - above-the-fold(初期表示領域)の画像は
priority属性を付与して優先読み込みする- 候補者詳細画面のメイン画像
- home画面の最初の数枚の候補者カード画像
- 候補者画像は適切な
sizesを指定してレスポンシブに最適化 - next/image の自動WebP/AVIF変換・リサイズ・lazy loading を活用
フォント最適化
next/font/google経由で Webフォントを読み込む(FOIT/FOUT を抑制し CLS を防止)- フォントのプリロードは Next.js が自動で実施
バンドル最適化
- 重いクライアントライブラリ(
react-slick,motionなど)は使用画面のみで動的 import を検討 - shadcn/ui は使用するコンポーネントのみコピーして含める(不要なものは取り込まない)
レンダリング最適化
- 候補者カードなどリスト要素は
React.memo化、コールバックはuseCallbackで安定化 - アニメーションは
motionのtransform/opacityベースで GPU 加速を活用
セキュリティポリシー(全画面共通)
各 spec の requirements.md で個別に記載しないでよい、サイト全体の共通セキュリティポリシーを定める。
認証・セッション管理
- OAuth 2.0 + PKCE フロー必須: ソーシャルログインは PKCE (Proof Key for Code Exchange) フローを使用する
- state パラメータ検証: OAuth リダイレクト時のCSRF攻撃を防ぐため、
stateパラメータを生成・検証する - HttpOnly Cookie でセッション管理: アクセストークン・セッションIDは
HttpOnly,Secure,SameSite=Lax属性付きCookieで管理する - クライアント側にトークンを露出させない: アクセストークンはサーバー側のみで保持し、
localStorage/sessionStorageには保存しない - NextAuth.js (Auth.js) を採用: 上記のベストプラクティスを標準提供
CSRF 対策
- CSRF トークン: 状態を変更するすべての Server Action / Route Handler は CSRF トークンを検証する
- NextAuth.js のミドルウェアまたは
next-csrfなどで実装
入力検証
- すべての外部入力を検証: クライアント側の検証だけでなく、サーバー側でも
zodなどのスキーマバリデーションで再検証する - SQLインジェクション対策: ORM (Prisma など) を使用し、生のクエリ文字列結合を禁止
- XSS 対策: React のデフォルトエスケープに依存し、
dangerouslySetInnerHTMLを原則禁止。やむを得ず使う場合は DOMPurify でサニタイズ
通信
- HTTPS 必須: 本番環境のすべての通信は HTTPS で行う(Vercel デプロイ時はデフォルト)
- CSP (Content Security Policy) 設定:
next.config.tsのheadersでdefault-src 'self'を基本に必要なドメインを許可 - HSTS ヘッダー: ブラウザに HTTPS を強制する
Strict-Transport-Securityを設定
レート制限・不正対策
- 投票エンドポイント: 同一ユーザー/IPからの過剰な投票リクエストを
Upstash Rate Limitなどでブロック - 時刻判定はサーバー側: 「1日1票」のような時刻依存ロジックはクライアント時刻に依存させず、サーバー側で判定する
- 重複防止: 投票・決済はサーバー側で冪等キーを用いて重複処理を防止する
決済
- PCI DSS スコープ最小化: カード情報はサーバーに保存せず、Stripe Elements / Checkout を使ってクライアント側でトークン化する(SAQ-A 対応)
- Webhook 署名検証: Stripe からの Webhook は
stripe.webhooks.constructEventで署名検証を行う - Webhook 冪等性: 同じ
payment_intent.succeededを重複処理しないよう、冪等キーで制御
個人情報
- 収集最小化: 氏名・メールアドレス以外の個人情報は原則収集しない
- ログマスキング: ログ出力時にメールアドレス・カード情報などをマスクする
- 保存期間: 投票履歴は集計後に匿名化、購入履歴は税法上の保存期間(7年)を超えて保持しない
環境変数
- シークレット管理: API キー・OAuth シークレットは Vercel の環境変数に保存し、リポジトリにコミットしない
- クライアント露出:
NEXT_PUBLIC_プレフィックスが付くものは公開前提として扱い、シークレットは絶対に付与しない
監査ログ(本番)
- 認証失敗・決済成功/失敗・管理者操作などの重要イベントをログに記録し、改竄不能な形で保管する
テスト
- 単体: Vitest + Testing Library
- E2E: Playwright(投票フロー・決済フローを優先)