2021年の木曜日の午後、バースを拠点とする骨董品商人の顧客から電話を受けました。毎月行っている対面オークションをオンライン化したいということでした。簡単だと思いました。その後、彼は「そして入札が、ページ更新なしにリアルタイムで誰もが見ている状態で更新される必要がある」と言いました。なるほど。それは「シンプルなWordPress案件」が2週間のアーキテクチャの議論に変わった瞬間です。
重要なポイント:Next.js と Supabase を使用したライブ入札は、リアルタイムチャネル、行レベルセキュリティ、サーバーサイドの入札検証に依存しており、UI を構築する前に状態マシンを正しく設計することが重要です。
Seahawk Mediaで12,000以上のサイトを構築してきましたが、リアルタイム機能は最初から適切に計画しないと痛い目を見るものです。5秒ごとのポーリングは聞こえは良いですが、200人の入札者が同時に単一のエンドポイントにアクセスし、ホスティング料金が一晩で倍になるまでです。だから今日、Next.jsとSupabaseを使って適切なライブオークションプラットフォームをどう構築するか、実際に出荷してきたものに基づいて説明します。
---
なぜこの場合はNext.jsとSupabaseなのか
リアルタイムには数十の方法があります。ノードサーバー上のSocket.io、Ably、Pusher、Firebase、様々な場面でこれら全てを使ってきました。しかしNext.js + Supabaseの組み合わせは特定の理由からここに値する場所を勝ち取ります。Supabase RealtimeはpostgreSQLの論理レプリケーションの上に構築されているため、ライブ入札の更新と永続的なデータレイヤーが同じシステムになります。2つの真実のソースを同期する必要がありません。WebSocketに入った入札がデータベースにも入ったかどうか疑問に思うことがありません。
Supabaseはさらに、認証、行レベルセキュリティ、ストレージをそのまま提供しています。「オークション所有者だけがロットを終了できる」「ユーザーは自分のアイテムに入札できない」といった実際のビジネスルールがあるオークションサイトでは、PostgresのRLSポリシーが本当に正しいツールです。
Next.jsは、正直に言うと、App RouterとServer Componentsによって、オークションカタログを静的にレンダリングし、SEOを満足させ、リアルタイム入札ウィジェットだけをクライアント上でハイドレートできるからです。その分割が重要です。ページの90%が静的コンテンツのページに対して動的レンダリングの費用をかけたくありません。
---
スキーマ設計を最初に行う(スキップするな)
ほとんどの人がここで急いで、後で後悔します。私はBath antiques クライアントのスキーマを、入札履歴モデルを適切に考えなかったせいで、プロジェクト途中で3日間も恥ずかしい思いをしてリファクタリングしました。
現在使っているコア構造は以下の通りです:
- `profiles`、Supabaseのauth.usersを拡張し、表示名、認証済み入札者フラグ、およびデポジットベースの入札を行う場合のcredit_balanceを保存
- `auctions`、イベント自体。starts_at、ends_at、status(draft | live | closed)、およびcreated_by
- `lots`、オークション内の個別商品。reserve_price、current_bid、current_bidder_id、lot_number、ends_at(ロットは個別のカウントダウンを持つことができます)
- `bids`、不変の追記のみログ。lot_id、bidder_id、amount、placed_at。このテーブルは決して更新しません。絶対に。
- `auction_participants`、誰がどのオークションに登録したかを追跡するジョインテーブル(デポジットホールドと通知ターゲティングに便利)
lotsのcurrent_bidとcurrent_bidder_idカラムは意図的に非正規化されています。bidsテーブルから毎読み取り時にそれらを導出することもできますが、並行負荷下ではそのクエリはすぐに重くなります。非正規化して、bidsテーブルを監査ログとして保持し、Postgresの関数を使用して入札が受け入れられたときにlotsをアトミックに更新します。
アトミック入札関数
ほとんどのチュートリアルが飛ばす部分がこれです。オークションにおけるレース条件は現実的な問題です。2人のユーザーが同じミリ秒に£520を入札した場合、何が起こるでしょうか?
答えはlot行に対するFOR UPDATEロックを使ったPostgres関数です。
``` 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; ```
supabase.rpc('place_bid', {...})経由でNext.jsのAPIルートからこれを呼び出します。FOR UPDATEロックは、任意の瞬間に1つのトランザクションだけがlot当たりで勝つことを意味します。もう一方はシリアライゼーションエラーを取得し、クライアント側では「誰かが今あなたを上回った」というメッセージを返します。
---
Row Level Security、オークションルールレイヤー
RLSは開発者が即座に愛するか、それが不透明に感じるため避けるのか、どちらかのものの1つです。Seahawk でのフィンテックプロジェクトが設定ミスのAPIルートで1つ離れている災害であることを私に厳しい方法で教えるまで、私は回避キャンプにいました。アプリケーションコードにのみアクセス制御を適用します。
オークションサイトの場合、ここが重要なポリシーです:
- 誰でもライブロットを閲覧できます。auctions.status = 'live'のロットに対するSELECT
- 認証済みで検証されたビッダーのみが入札を挿入できます。ポリシーで profiles.verified_bidder = true をチェックします
- オークション作成者のみがロットステータスを更新できます。auctions.created_by = auth.uid()のロットに対するUPDATE
- 入札履歴はロットのオークション作成者と入札者本人のみが閲覧できます。他の誰も入札履歴全体をリアルタイムで確認する必要はありません
Supabase のRLSドキュメントはここで本当に優れています。security definer関数のセクションを読む価値があります。place_bid のようなRPCコールがどう機能するかと相互作用するためです。
1つの落とし穴:Postgres関数でsecurity definerを使う場合(上記の通り)、関数オーナーの権限で実行され、RLSをバイパスします。これは意図的です。入札配置をビッダーのRLSをバイパスさせることで、ロット行をロックして更新できるようにしたいのです。ただし、関数内で独自のビジネスロジックチェックを実施する必要があります。上記のコードがそれを行っています。
---
Next.jsでのSupabase Realtimeのセットアップ
ここからが本当に満足できる部分です。Supabase Realtime を使うと、WebSocket を経由して Postgres テーブルの変更をサブスクライブでき、クライアント SDK がほぼ信じられないほどシンプルに実装できます。
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()
return () => { supabase.removeChannel(channel) } }, [lotId])
return <div>Current bid: £{currentBid.toLocaleString()}</div> } ```
リクエスト時に新鮮なデータを取得するServer ComponentからinitialBidを渡す。その後クライアントが引き継いで、その特定のロット行のUPDATEイベントをリッスンする。place_bidが正常に実行されるたびに、Supabaseは変更をブロードキャストして、接続されているすべての入札者のUIは通常100~300ミリ秒以内に更新される。
カウントダウンタイマーの処理
ロットは通常カウントダウンを持ちます。「残り3:42」といった表示です。クライアントクロックを信頼してはいけません。lots.ends_at(Postgresに UTC で保存)から終了時刻を導出し、クライアントで Date.now() を使って残りの秒数を計算してください。60秒ごとに新しいフェッチでそれを再同期して、ドリフトに対応してください。また「ソフトクローズ」ロジックを追加してください。最後の60秒以内に入札が到着した場合、ends_at を2分間延長します。これは標準的なオークション動作で、ビッダーはこれを期待しています。
---
オークション UI 用の Next.js App Router アーキテクチャ
使用するページ構造は以下の通りです。
app/ auctions/ page.tsx ← サーバーコンポーネント、ライブオークション一覧表示(ISR、revalidate: 60) [auctionId]/ page.tsx ← サーバーコンポーネント、サーバー側でロット一覧を取得 LotGrid.tsx ← クライアントコンポーネント、ロット状態変化をサブスクライブ [lotId]/ page.tsx ← サーバーコンポーネント、初期ロットデータ+SEOメタデータ BidPanel.tsx ← クライアントコンポーネント、リアルタイム入札表示+入札フォーム
カタログ(/auctions)はIncremental Static Regenerationを使用し、60秒の再検証間隔で動作します。個別のロットページは初回読み込み時にサーバーサイドでレンダリングされ(共有、プレビュー、og:image生成用)、その後はライブ機能用のクライアントコンポーネントに処理を引き継ぎます。
私がいつもやることの1つ:BidPanelコンポーネントをdynamic(() => import('./BidPanel'), { ssr: false })の背後で遅延ロードしておくこと。どのみちクライアント側でしか意味をなさないし、接続が遅いユーザーの初期HTMLペイロードをスリムに保つ。低速接続がより重要な傾向にあるのはアンティークオークションのような場合(そのオーディエンスが年配層に偏っているため)で、予想以上に影響がある。
---
認証と「認証済み入札者」フロー
標準的なSupabase Authでメール/パスワードまたはマジックリンクを使ったサインアップは問題なく機能します。ただしオークションは往々として追加ステップが必要です:入札者の認証です。入札前にクレジットカードの保留、身分証明書の確認、または管理者による承認が必要かもしれません。
私が使うパターン:profiles テーブルの verified_bidder という真偽値で、デフォルトは false です。サインアップ後、ユーザーは「登録を完了してください」画面を見ます。承認されたら(管理者が手動で、または Stripe 支払い認可後に自動で)、フラグをオンにします。bids の RLS ポリシーはこれをチェックします。入札者は閲覧や ウォッチはできますが、検証されるまで入札はできません。
Stripe決済の認可ホールドについては、capture_method: manualを使ったStripeのpayment intentsが正解。£50のホールドを認可して、入札者が落札したらキャプチャして、落札しなかったらリリースする。こうすることで支払い拒否が劇的に減る。信じてくれ、それはオンラインオークション運営者の存在そのものの悩みの種だ。
---
デプロイ、パフォーマンス、そして後になって痛い目を見るポイント
Vercelにデプロイするのが当たり前の選択肢。Next.jsにピッタリで、エッジネットワークがSupabaseのグローバルインフラとうまく機能する。SupabaseプロジェクトがVercelのデプロイメント領域に最も近いAWS領域にあることを確認すること。Vercelをus-east-1に、Supabaseをeu-west-2にデプロイした誰かのせいで、完全に不要な40~60msのレイテンシが生じているのを見たことがある。1つの領域を選んで、両方をそこに置け。
事前に対処しないと痛い目を見る項目がいくつかあります:
- WebSocket接続制限。Supabaseの無料版は約200の同時Realtimeコネクションを許可します。オークションがバイラル化した場合、このキャップが重要です。プランを確認してください。
- 入札のオプティミスティックUI。サーバーが確認する前に、入札をすぐに入札者の画面に表示する。失敗したら(上回られた、競合状態)エラーで戻す。200~300msのサーバーラウンドトリップはUIがそれを待たない限り知覚されない。
- ロット終了の猶予期間。ends_at ちょうどでロットを閉じるな。期限直前に送信されたが進行中の入札を処理するため、2~3秒のサーバー側バッファを与える。close_lot スケジュール関数で対応する。
- メール通知。Supabase Edge FunctionsとResendまたはPostmarkを使って「You've been outbid」と「You won!」メールを送る。Next.jsのAPIルートからこれをやろうとするな。タイムアウトする可能性があって、オークション参加者は通知が信頼できないと本当にイライラする。
---
よくある質問
Supabase Realtimeは何人の同時入札者を処理できますか?
SupabaseのProプランはデフォルトで500の同時Realtimeコネクションをサポートし、より高い制限も利用可能。ほとんどのオークションサイトでは、Sotheby'sのオンラインと同じ規模で運営しているのでなければ、それ以上で十分。数千の同時閲覧者を想定しているなら、ユーザーごとのサブスクリプションではなく、シングルサーバーサイドチャネルを通じてロット更新をブロードキャストすることを考えて、Supabaseの Realtime Broadcast機能(高ファンアウトシナリオでより効率的)を見てくれ。
Supabase Realtimeを使用するべき、それともAblyのような専用サービスを使用するべき?
ほとんどのプロジェクトではSupabase Realtimeで十分で、データが既にSupabaseにあるため統合がずっとシンプル。グローバルに50ms以下のレイテンシが必要だったり、数百万の同時コネクションを構築していない限り、AblyやPusherには手を出さないだろう。アンティークオークション、チャリティ募金、小規模なアートギャラリーのオンラインセール—Supabaseはこれらすべてに対応できる。
オークション中にユーザーの WebSocket 接続が切れた場合、どうなりますか?
SupabaseのクライアントSDKは自動的に再接続を試みる。ただし再接続時には、ドロップ前のローカル状態に存在したものを信頼するのではなく、常に現在のロット状態(current_bid、ends_at)を再取得すべき。Client Componentにはオンライン/オフラインイベントリスナーを追加して、接続が回復したときに新しいサーバー取得をトリガーする。
Next.js Server Actions を使用して API ルートの代わりに入札を行うことはできますか?
できた、やった。Next.js 14のServer Actionsは便利で、専用の/api/bidルートのボイラープレートを排除する。トレードオフは、Server Actionsは個別のレート制限を少し難しくする(ミドルウェアレベルでレート制限を適用することになり、アクションごとではない)。本番のオークションサイトなら、ActionsかAPIルートかにかかわらず、単一ユーザーが入札リクエストをスパムするのを防ぐために、ミドルウェアにUpstash Redisレート制限を追加するだろう。
タイについてはどうするか。同じ時刻に同じ金額の入札が2つ入った場合は?
place_bid Postgres関数のFOR UPDATEロックは同時入札をシリアライズするため、技術的にはデータベースレベルではタイが起こりえない。1つは成功し、もう1つは「入札が低すぎる」レスポンスで失敗する(両方ともcurrent_bidと等しく、チェックがp_amount <= v_lot.current_bidだから)。先着順。それは標準的なオークション慣行で、ほとんどの入札者はそれを理解している。
---
Bath地区のアンティークディーラー、ちなみに、2年以上前からオンラインで月次オークションを実施している。あるある土曜日の夜のピーク同時入札者は84人。彼の村全体が、争点のないジョージア様式の銀製品が予備価格の3倍で落札されるのを見るために集まった状態だった。Supabaseはひるまなかった。Next.jsもひるまなかった。唯一壊れたのは彼のWi-Fiで、ショップフロアから運営していたからだ。
リアルタイムは事前に考え抜くのは難しいが、スキーマが堅牢で原子性のある入札関数が適切に配置されれば、残りはほぼ配管だ。基礎を正しく構築すれば、時間を使うのはファンな部分—カウントダウンアニメーション、「going once, going twice」UX—であって、深夜のレース条件のデバッグではない。
