2021年の木曜日の午後、ある顧客から電話がありました。バースを拠点とする骨董品商で、月ごとの対面オークションをオンライン化したいとのこと。簡単だと思いました。その時、彼は「入札は視聴している全員にリアルタイムで更新される必要があり、ページリロードなしで」と言いました。そのとき、「シンプルなWordPress案件」は2週間のアーキテクチャ議論に変わりました。
私はSeahawk Mediaで5,000以上のサイトを構築してきましたが、リアルタイム機能は、最初から適切に計画しないと問題になるものです。5秒ごとのポーリングは良さそうに聞こえますが、200人の入札者が同時に1つのエンドポイントをたたき始めると、ホスティング費用が一夜にして2倍になります。ですから、今日のような適切なライブオークションプラットフォームをNext.jsとSupabaseを使ってどのように構築するかを紹介します。実際に出荷したものに基づいています。Seahawk Media, and real-time features are the ones that bite you if you don't plan them properly from the start. Polling every five seconds sounds fine until you have 200 bidders hammering a single endpoint simultaneously and your hosting bill doubles overnight. So let me show you exactly how I'd build a proper live auction platform today — usingNext.jsandSupabase— based on what I've actually shipped.
---
なぜこの場合はNext.jsとSupabaseなのか
見てください、リアルタイムを実現する方法は十数種類あります。ノードサーバー上のSocket.io、Ably、Pusher、Firebase — さまざまな時点で使用してきました。しかし、Next.js + Supabaseの組み合わせがこの場で適切な理由は、特定の理由があります。Supabase Realtimeはpostgresqlの論理レプリケーションの上に構築されています。つまり、ライブ入札の更新と永続的なデータレイヤーが同じシステムです。2つの情報源を同期する必要がありません。WebSocketに入った入札がデータベースにも入ったかどうかを疑う必要もありません。same system. No syncing two sources of truth. No wondering if a bid that went into the WebSocket also made it into the database.
Supabaseはさらに、認証、行レベルセキュリティ、ストレージをそのまま提供しています。「オークション所有者だけがロットを終了できる」「ユーザーは自分のアイテムに入札できない」といった実際のビジネスルールがあるオークションサイトでは、PostgresのRLSポリシーが本当に正しいツールです。
Next.jsを選んだ理由は、率直に言って、App RouterとServer Componentsにより、オークションカタログを静的にレンダリングしてSEOを満足させ、リアルタイム入札ウィジェットだけをクライアント側でハイドレートできるからです。この分け方が重要です。コンテンツの90%が静的なページに対して、ダイナミックレンダリングの費用を払いたくはありません。
---
スキーマ設計を最初に行う(スキップするな)
ほとんどの人がここで急いで、後で後悔します。私はBath antiques クライアントのスキーマを、入札履歴モデルを適切に考えなかったせいで、プロジェクト途中で3日間も恥ずかしい思いをしてリファクタリングしました。
現在使っているコア構造は以下の通りです:
- `profiles` — Supabaseの auth.users を拡張し、表示名、認証済み入札者フラグ、デポジットベースの入札を行う場合は credit_balance を保存— extends Supabase's
auth.users, stores display name, verified bidder flag, and acredit_balanceif you're doing deposit-based bidding - `auctions` — イベント本体;starts_at、ends_at、status(draft | live | closed)、created_by— the event itself;
starts_at,ends_at,status(draft | live | closed), andcreated_by - `lots` — オークション内の個別アイテム;reserve_price、current_bid、current_bidder_id、lot_number、ends_at(ロットは個別のカウントダウンを持つことができます)— individual items within an auction;
reserve_price,current_bid,current_bidder_id,lot_number,ends_at(lots can have individual countdowns) - `bids` — 不変の追記のみログ;lot_id、bidder_id、amount、placed_at。このテーブルは絶対に更新しないでください。— immutable append-only log;
lot_id,bidder_id,amount,placed_at. Never update this table. Ever. - `auction_participants` — 誰がどのオークションに登録したかを追跡するジョインテーブル(デポジット保留と通知ターゲティングに有用)— a join table tracking who has registered for which auction (useful for deposit holds and notification targeting)
lots テーブルの current_bid と current_bidder_id カラムは意図的に非正規化されています。毎回の読み取り時に bids テーブルから派生させることは可能ですが、並行負荷下ではそのクエリはすぐにコストが高くなります。非正規化して、bids テーブルを監査ログとして保持し、入札が受け入れられたときに Postgres 関数を使用して lots をアトミックに更新します。current_bidandcurrent_bidder_idcolumns onlotsare denormalised intentionally. Yes, you could derive them from thebidstable on every read, but under concurrent load that query gets expensive fast. Denormalise it, keep thebidstable as your audit log, and use a Postgres function to updatelotsatomically when a bid is accepted.
アトミック入札関数
ほとんどのチュートリアルが飛ばす部分です。オークションにおける競合状態は現実です。2 人のユーザーが同じミリ秒で £520 を送信した場合、何が起こるのでしょうか?real. Two users submitting £520 at the same millisecond — what happens?
答えは lot 行に FOR UPDATE ロックを持つ Postgres 関数です:FOR UPDATElocking on the lot row:
``` create or replace function place_bid(p_lot_id uuid, p_bidder_id uuid, p_amount numeric) returns json as $$ declare v_lot lots%rowtype; begin select * into v_lot from lots where id = p_lot_id for update;
if v_lot.status != 'live' then return json_build_object('success', false, 'error', 'Lot is not live'); end if;
if p_amount <= v_lot.current_bid then return json_build_object('success', false, 'error', 'Bid too low'); end if;
if p_bidder_id = v_lot.current_bidder_id then return json_build_object('success', false, 'error', 'You are already the highest bidder'); end if;
insert into bids (lot_id, bidder_id, amount) values (p_lot_id, p_bidder_id, p_amount);
update lots set current_bid = p_amount, current_bidder_id = p_bidder_id where id = p_lot_id;
return json_build_object('success', true, 'new_bid', p_amount); end; $$ language plpgsql security definer; ```
Next.js APIルートから supabase.rpc('place_bid', {...}) 経由でこれを呼び出します。FOR UPDATE ロックは、任意の時点でロットごとに1つのトランザクションだけが勝つことを意味します。もう一方はシリアライゼーションエラーを取得し、クライアント上で「誰かがちょうど入札を上回った」というフレンドリーなメッセージを返します。supabase.rpc('place_bid', {...}). TheFOR UPDATElock means only one transaction wins per lot at any given moment. The other one gets a serialisation error and you return a friendly "someone just outbid you" message on the client.
---
Row Level Security — オークションルールレイヤー
RLSは開発者が即座に愛するか、それが不透明に感じるため避けるのか、どちらかのものの1つです。Seahawk でのフィンテックプロジェクトが設定ミスのAPIルートで1つ離れている災害であることを私に厳しい方法で教えるまで、私は回避キャンプにいました。アプリケーションコードにのみアクセス制御を適用します。
オークションサイトの場合、ここが重要なポリシーです:
- 誰でも生きているロットを読むことができます — auctions.status = 'live' の lots に対する SELECT—
SELECTonlotswhereauctions.status = 'live' - 認証済みで検証済みの入札者だけが入札を挿入できます — ポリシーで profiles.verified_bidder = true を確認します— check
profiles.verified_bidder = truein the policy - オークション作成者だけがロットステータスを更新できます — auctions.created_by = auth.uid() の lots の UPDATE—
UPDATEonlotswhereauctions.created_by = auth.uid() - 入札履歴は、ロットのオークション作成者と入札者本人だけが閲覧可能です。それ以外の誰かがリアルタイムで完全な入札履歴を見る必要はありません。— no one else needs to see full bid history in real time
Supabase RLSドキュメンテーションはここで本当に優れています。セキュリティ定義関数に関するセクションを読む価値があります。なぜなら、place_bidのようなRPC呼び出しがどのように相互作用するかに関わってくるからです。Supabase RLS documentationis genuinely good here — worth reading the section on security definer functions, because it interacts with how RPC calls likeplace_bidwork.
1つの落とし穴として、Postgres関数でセキュリティ定義を使用した場合(上記のように)、関数の所有者の権限で実行され、RLSをバイパスします。これは意図的です。入札配置がロット行をロックおよび更新できるように、入札者のRLSをバイパスしたいのです。ただし、関数内で独自のビジネスロジックチェックを強制する必要があり、上記のコードがそれを行っています。security defineron your Postgres function (as above), it runs with the function owner's privileges, bypassing RLS. That's intentional — youwantthe bid placement to bypass the bidder's RLS so it can lock and update the lot row. But it means you must enforce your own business logic checks inside the function, which the code above does.
---
Next.jsでのSupabase Realtimeのセットアップ
ここが本当に満足度が高くなるポイントです。Supabase Realtimeでは、内部的にWebSocketsを使用してPostgresテーブルの変更にサブスクライブでき、クライアントSDKがそれをほぼ信じられないほど簡単にしています。WebSockets underneath, and the client SDK makes it almost embarrassingly simple.
オークションロットページ(Next.js App RouterのClient Component)では、以下のような操作をします。
``` 'use client'
import { useEffect, useState } from 'react' import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
export default function LotBidDisplay({ lotId, initialBid }) { const [currentBid, setCurrentBid] = useState(initialBid) const supabase = createClientComponentClient()
useEffect(() => { const channel = supabase .channel(lot-${lotId}) .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'lots', filter: id=eq.${lotId} }, (payload) => { setCurrentBid(payload.new.current_bid) } ) .subscribe()lot-${lotId}).on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'lots', filter:id=eq.${lotId}}, (payload) => { setCurrentBid(payload.new.current_bid) } ).subscribe()
return () => { supabase.removeChannel(channel) } }, [lotId])
return <div>Current bid: £{currentBid.toLocaleString()}</div> } ```
初期入札額をサーバーコンポーネントから渡します。サーバーコンポーネントはリクエスト時に最新データを取得します。その後、クライアントが引き継ぎ、特定のロット行の UPDATE イベントをリッスンします。place_bid が正常に実行されるたびに、Supabase が変更をブロードキャストし、接続されているすべての入札者の UI は通常約 100~300ms以内に更新されます。initialBidfrom a Server Component that fetches fresh data at request time. The client then takes over, listening forUPDATEevents on that specific lot row. Every timeplace_bidruns successfully, Supabase broadcasts the change and every connected bidder's UI updates within about 100–300ms typically.
カウントダウンタイマーの処理
ロットには通常、カウントダウン(「残り 3:42」など)があります。クライアントクロックを信頼しないでください。lots.ends_at(Postgres に UTC で保存)から終了時刻を取得し、クライアント上で Date.now() を使用して残り秒数を計算してください。ドリフトに対応するため、60 秒ごとに新規取得で再同期してください。また「ソフトクローズ」ロジックを追加してください。最後の 60 秒以内に入札が到着したら、ends_at を 2 分延長します。これは標準的なオークション動作であり、入札者はこれを期待しています。nottrust the client clock for this. Derive the end time fromlots.ends_at(stored in UTC in Postgres) and calculate remaining seconds on the client usingDate.now(). Re-sync it every 60 seconds with a fresh fetch in case of drift. And add "soft close" logic: if a bid arrives within the last 60 seconds, extendends_atby two minutes. That's standard auction behaviour and bidders expect it.
---
オークション UI 用の Next.js App Router アーキテクチャ
使用するページ構造は以下の通りです。
`` app/ auctions/ page.tsx ← Server Component、ライブオークションを一覧表示(ISR、revalidate: 60) [auctionId]/ page.tsx ← Server Component、ロット一覧をサーバー側で取得 LotGrid.tsx ← Client Component、ロットステータスの変更をサブスクライブ [lotId]/ page.tsx ← Server Component、初期ロットデータ + SEO メタデータ BidPanel.tsx ← Client Component、リアルタイム入札表示 + 入札フォーム ``app/ auctions/ page.tsx ← Server Component, lists live auctions (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, fetches lots list server-side LotGrid.tsx ← Client Component, subscribes to lot status changes [lotId]/ page.tsx ← Server Component, initial lot data + metadata for SEO BidPanel.tsx ← Client Component, real-time bid display + bid form``
カタログ(/auctions)はIncremental Static Regenerationを使用し、60秒の再検証間隔で動作します。個別のロットページは初回読み込み時にサーバーサイドでレンダリングされ(共有、プレビュー、og:image生成用)、その後はライブ機能用のクライアントコンポーネントに処理を引き継ぎます。/auctions) uses Incremental Static Regeneration with a 60-second revalidation. Individual lot pages render server-side on first load (for sharing, previewing, og:image generation), then hand off to client components for the live stuff.
常にやることの一つ:BidPanelコンポーネントをdynamic(() => import('./BidPanel'), { ssr: false })で遅延ロードさせることです。どうせクライアントサイドでしか意味がないし、遅い接続のユーザーに対して初期HTMLペイロードを軽くしておく — 特にオークション利用者が高年齢層寄りの場合(アンティークオークションはたいていそう)、予想以上に重要になります。BidPanelcomponent lazy-loaded behinddynamic(() => import('./BidPanel'), { ssr: false }). It only makes sense client-side anyway, and it keeps your initial HTML payload lean for users on slow connections — which, if your auction audience skews older (as antique auctions tend to), matters more than you'd expect.
---
認証と「認証済み入札者」フロー
標準的なSupabase Authでメール/パスワードまたはマジックリンクを使ったサインアップは問題なく機能します。ただしオークションは往々として追加ステップが必要です:入札者の認証です。入札前にクレジットカードの保留、身分証明書の確認、または管理者による承認が必要かもしれません。
私が使うパターン:profilesテーブルにverified_bidderというboolean値を持たせ、デフォルトはfalseです。サインアップ後、ユーザーには「登録を完了してください」画面が表示されます。承認されたら(管理者による手動承認、またはStripeの支払い認可後に自動)、そのフラグを切り替えます。bidsのRLSポリシーがそれをチェックします。閲覧、ウォッチは可能ですが、認証されるまで入札はできません。verified_bidderboolean on theprofilestable, defaulting tofalse. After sign-up, the user sees a "Complete your registration" screen. Once approved (manually by admin, or automatically after a Stripe payment authorisation), you flip the flag. The RLS policy on bids checks it. They can browse, watch, but not bid until verified.
Stripe支払い認可ホールドの場合、Stripeのpayment intentsでcapture_method: manualを使うのが正解です — £50のホールドを認可し、落札したらキャプチャ、落札しなかったらリリースする。これで未払い状況が劇的に減ります。これはね、オンラインオークション事業者の悩みの種なんです、本当に。Stripe's payment intents with capture_method: manualis the right approach — you authorise a £50 hold, capture it if they win, release it if they don't. This dramatically reduces no-pay situations which, trust me, are the bane of every online auction operator's existence.
---
デプロイ、パフォーマンス、そして後になって痛い目を見るポイント
Vercelにデプロイしてください — Next.jsの明白な選択肢ですし、エッジネットワークはSupabaseのグローバルインフラとうまく機能します。SupabaseプロジェクトはVercelのデプロイリージョンに最も近いAWSリージョンに配置してください。Vercelをus-east-1に、Supabaseをeu-west-2にデプロイしてしまったせいで、完全に不要な40〜60msのレイテンシが発生するケースを見てきました。リージョンは1つに決めて、両方そこに置いてください。Vercel— it's the obvious choice for Next.js and the edge network plays well with Supabase's global infrastructure. Make sure your Supabase project is in the AWS region closest to your Vercel deployment region. I've seen 40–60ms of completely unnecessary latency because someone deployed Vercel inus-east-1and Supabase ineu-west-2. Pick one region, put both there.
事前に対処しないと痛い目を見る項目がいくつかあります:
- WebSocket接続制限。Supabaseの無料プランは約200の同時Realtime接続を許可しています。あなたのオークションがバイラルになったら、この上限は重要です。あなたのプランを確認してください。Supabase's free tier allows around 200 concurrent Realtime connections. If your auction goes viral, that cap matters. Check your plan.
- 入札のOptimistic UI。サーバーが確認する前に入札者の画面に入札を即座に表示します。失敗した場合(上回られた、競合状態)、エラーで戻します。200~300msのサーバーラウンドトリップは、UIが待つのでなければ知覚されません。Show the bid immediately on the bidder's screen before the server confirms. If it fails (outbid, race condition), revert with an error. The 200–300ms server round-trip is imperceptibleunlessthe UI waits for it.
- ロット終了の猶予期間。ends_atで正確にロットを閉じないでください。期限直前に送信されたいかなる入札をも処理できるように、2~3秒のサーバー側バッファを与えてください。あなたのclose_lotスケジュール関数でこれを処理してください。Never close a lot exactly at
ends_at. Give it a 2–3 second server-side buffer to allow in-flight bids that were submitted just before the deadline to process. Handle this in yourclose_lotscheduled function. - メール通知。Supabase Edge FunctionsをResendまたはPostmarkと一緒に使用して、「あなたは上回られました」と「あなたが勝ちました!」メールを送信してください。Next.js APIルートからこれをしようとしないでください — タイムアウトする可能性があり、オークション参加者は通知が信頼できない場合、本当にイライラします。Use Supabase Edge Functions with Resend or Postmark to send "You've been outbid" and "You won!" emails. Don't try to do this from your Next.js API routes — they can time out, and auction participants get genuinely annoyed if notifications are unreliable.
---
よくある質問
Supabase Realtimeは何人の同時入札者を処理できますか?
SupabaseのProプランはデフォルトで最大500の同時Realtime接続をサポートしており、より高い制限が利用可能です。ほとんどのオークションサイト — Sotheby'sオンラインのサイズで何かを実行していない限り — それで十分です。数千の同時閲覧者を予想する場合、ロット更新をユーザーごとのサブスクリプションではなく単一のサーバー側チャネルを通じてブロードキャストすることを検討し、高いファンアウトシナリオではより効率的なSupabaseのRealtime Broadcast機能を確認してください。Realtime Broadcastfeature which is more efficient for high fan-out scenarios.
Supabase Realtimeを使用するべき、それともAblyのような専用サービスを使用するべき?
ほとんどのプロジェクトでは、Supabase Realtime で十分に対応でき、データが既に Supabase にあるため統合ははるかにシンプルです。グローバルに 50ms 未満のレイテンシが必要な場合、または数百万の同時接続を構築する場合にのみ、Ably または Pusher を検討します。アンティーク・オークション、チャリティー募金活動、小規模アート・ギャラリーのオンライン販売 — Supabase はこれらすべてに問題なく対応します。
オークション中にユーザーの WebSocket 接続が切れた場合、どうなりますか?
Supabase のクライアント SDK は自動的に再接続を試みます。ただし、再接続時に接続切断前のローカル状態を信頼するのではなく、常に現在のロット状態(current_bid、ends_at)を再取得すべきです。Client Component にオンライン/オフライン イベント リスナーを追加し、接続が復旧した時点でサーバーからの新規フェッチをトリガーしてください。current_bid,ends_at) on reconnection rather than trusting whatever was in local state before the drop. Add anonline/offlineevent listener in your Client Component and trigger a fresh server fetch when the connection restores.
Next.js Server Actions を使用して API ルートの代わりに入札を行うことはできますか?
はい、私も実行しています。Next.js 14 の Server Actions は便利です。専用の /api/bid ルートのボイラープレートを削除します。トレードオフとして、Server Actions は個別のレート制限がやや難しくなります(Actions ごとではなくミドルウェアレベルでレート制限を適用します)。本番環階のオークションサイトの場合、ミドルウェアに Upstash Redis レート制限を追加して、Actions か API ルートかに関わらず、単一ユーザーが入札リクエストをスパムするのを防ぎます。/api/bidroute. The tradeoff is that Server Actions are a bit harder to rate-limit individually (you'd apply rate limiting at the middleware level rather than per-action). For a production auction site, I'd addUpstash Redisrate limiting in middleware to prevent a single user from spamming bid requests regardless of whether you use Actions or API routes.
同じ金額の入札が同時に行われた場合、どのように処理しますか?
place_bid Postgres 関数の FOR UPDATE ロックが同時入札をシリアライズするため、技術的にはデータベースレベルではタイが発生しません。一方は成功し、もう一方は「入札が低すぎます」レスポンスで失敗します(両方とも current_bid に等しく、チェックは p_amount <= v_lot.current_bid であるため)。先着順です。これは標準的なオークション慣行であり、ほとんどの入札者がそれを理解しています。FOR UPDATElock in theplace_bidPostgres function serialises concurrent bids, so technically ties can't happen at the database level. One will succeed, the other will fail with a "bid too low" response (since both are equal tocurrent_bidand the check isp_amount <= v_lot.current_bid). First in, first served. That's standard auction practice and most bidders understand it.
---
バースのアンティーク商人は、参考までに述べると、2年以上前からオンラインで月例オークションを開催しています。ある土曜夜の最高同時入札者数は 84 人でした。彼の村全体が、ジョージアン・シルバーの紛争のあるロットが予約価格の 3 倍で落札されるのを見るために参加していました。Supabase は揺るがず。Next.js は揺るがず。唯一問題が生じたのは彼の Wi-Fi で、ショップフロアから実行していたからです。
リアルタイム処理は最初は複雑に見えますが、スキーマが堅実で原子的な入札関数が配置されれば、後のほとんどは単なる配管工事です。基礎をしっかり構築すれば、深夜レース条件のデバッグではなく、カウントダウンアニメーション、「一度目、二度目」という UX など、楽しい部分に時間を費やすことができます。
