2021年の木曜日の午後、クライアントから電話がありました。バースを拠点とする骨董品商で、毎月の対面オークションをオンライン化したいとのこと。シンプルな話だと思いました。その後、彼は「入札は、ライブで、ページを更新することなく、みんなが見ている間にリアルタイムで更新される必要がある」と言いました。なるほど。そこで「シンプルなWordPressの仕事」が2週間のアーキテクチャ議論に変わりました。WordPress job" turned into a two-week architecture conversation.
重要なポイント:Next.js と Supabase を使用したライブ入札は、リアルタイムチャネル、行レベルセキュリティ、サーバーサイドの入札検証に依存しており、UI を構築する前に状態マシンを正しく設計することが重要です。Live bidding on Next.js plus Supabase hinges on realtime channels, row-level security, and server-side bid validation; get the state machine right before the UI.
Seahawk Mediaで12,000以上のサイトを構築してきましたが、リアルタイム機能は最初から適切に計画しないと痛い目に遭うものです。5秒ごとにポーリングすることは、200人の入札者が同時に1つのエンドポイントをハンマーのように叩いてホスティング料金が一夜にして倍になるまでは問題なく聞こえます。では、実際に出荷したものに基づいて、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 -- using Next.js and Supabase -- based on what I've actually shipped.
---
なぜこの場合はNext.jsとSupabaseなのか
リアルタイムを実現する方法は十数通りあります。Node サーバー上の 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_bid and current_bidder_id columns on lots are denormalised intentionally. Yes, you could derive them from the bids table on every read, but under concurrent load that query gets expensive fast. Denormalise it, keep the bids table as your audit log, and use a Postgres function to update lots atomically when a bid is accepted.
アトミック入札関数
ほとんどのチュートリアルが省くのはこの部分だ。オークションのレース条件は現実に起こる。2人のユーザーが同じミリ秒で£520を入札したら、何が起こるのか?real. Two users submitting £520 at the same millisecond -- what happens?
答えはlot行に対するFOR UPDATEロックを使ったPostgres関数です。FOR UPDATE locking 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; ```
supabase.rpc('place_bid', {...})経由でNext.jsのAPIルートからこれを呼び出します。FOR UPDATEロックは、任意の瞬間に1つのトランザクションだけがlot当たりで勝つことを意味します。もう一方はシリアライゼーションエラーを取得し、クライアント側では「誰かが今あなたを上回った」というメッセージを返します。supabase.rpc('place_bid', {...}). The FOR UPDATE lock 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'のロットでSELECTする --
SELECTonlotswhereauctions.status = 'live' - 認証済みで検証済みの入札者だけが入札を挿入できる -- ポリシーでprofiles.verified_bidder = trueをチェックする -- check
profiles.verified_bidder = truein the policy - オークション作成者だけがロットステータスを更新できる -- auctions.created_by = auth.uid()のロットでUPDATEする --
UPDATEonlotswhereauctions.created_by = auth.uid() - 入札履歴はロットのオークション作成者と入札者自身が読める -- リアルタイムで完全な入札履歴を見る必要があるのはこれらの人だけだ -- no one else needs to see full bid history in real time
Supabase RLSドキュメンテーションは本当に質が高い -- security definer関数のセクションを読む価値がある。place_bidのようなRPCコールがどのように相互作用するかを理解するためだ。Supabase RLS documentation is genuinely good here -- worth reading the section on security definer functions, because it interacts with how RPC calls like place_bid work.
1つの落とし穴:Postgres関数でsecurity definerを使うと(上記のように)、関数所有者の権限で実行され、RLSをバイパスする。これは意図的だ -- 入札者のRLSをバイパスして入札配置がロット行をロックして更新できるようにしたいからだ。しかしそれはビジネスロジックチェックを関数の内部で強制する必要がある、上記のコードが行うようにだ。security definer on your Postgres function (as above), it runs with the function owner's privileges, bypassing RLS. That's intentional -- you want the 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 を使うと、WebSocket を経由して 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> } ```
リクエスト時に新鮮なデータを取得するServer ComponentからinitialBidを渡す。その後クライアントが引き継いで、その特定のロット行のUPDATEイベントをリッスンする。place_bidが正常に実行されるたびに、Supabaseは変更をブロードキャストして、接続されているすべての入札者のUIは通常100~300ミリ秒以内に更新される。initialBid from a Server Component that fetches fresh data at request time. The client then takes over, listening for UPDATE events on that specific lot row. Every time place_bid runs successfully, Supabase broadcasts the change and every connected bidder's UI updates within about 100-300ms typically.
カウントダウンタイマーの処理
ロットには通常カウントダウンがある――「あと3:42で終了」といった具合だ。クライアントの時刻を信用するな。ロットの ends_at(PostgresにUTCで保存されている)から終了時刻を取得し、Date.now()を使ってクライアント側で残り秒数を計算する。ドリフト対策として60秒ごとに新しいフェッチで再同期する。そして「ソフトクローズ」ロジックを追加する――入札が最後の60秒以内に到着したら、ends_at を2分延長する。これは標準的なオークション動作であり、入札者はそれを期待している。not trust the client clock for this. Derive the end time from lots.ends_at(stored in UTC in Postgres) and calculate remaining seconds on the client using Date.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, extend ends_at by two minutes. That's standard auction behaviour and bidders expect it.
---
オークション UI 用の Next.js App Router アーキテクチャ
使用するページ構造は以下の通りです。
app/ auctions/ page.tsx ← サーバーコンポーネント、ライブオークション一覧表示(ISR、revalidate: 60) [auctionId]/ page.tsx ← サーバーコンポーネント、サーバー側でロット一覧を取得 LotGrid.tsx ← クライアントコンポーネント、ロット状態変化をサブスクライブ [lotId]/ page.tsx ← サーバーコンポーネント、初期ロットデータ+SEOメタデータ BidPanel.tsx ← クライアントコンポーネント、リアルタイム入札表示+入札フォーム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.
常にやることが1つある――BidPanel コンポーネントを dynamic(() => import('./BidPanel'), { ssr: false }) の背後で遅延ロードする。どうせクライアント側でしか意味がないし、低速接続のユーザーにとって初期HTMLペイロードを軽くできる――特にオークション利用者が高齢層寄りの場合(骨董品オークションではそうなることが多い)、予想以上に重要だ。BidPanel component lazy-loaded behind dynamic(() => 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 という真偽値で、デフォルトは false です。サインアップ後、ユーザーは「登録を完了してください」画面を見ます。承認されたら(管理者が手動で、または Stripe 支払い認可後に自動で)、フラグをオンにします。bids の RLS ポリシーはこれをチェックします。入札者は閲覧や ウォッチはできますが、検証されるまで入札はできません。verified_bidder boolean on the profiles table, defaulting to false. 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決済の認可保留については、capture_method: manual を設定したStripeのpayment intentsが正解だ――£50の保留を認可し、落札したら引き落とし、落札しなかったら解放する。これにより無払いのケースが劇的に減る。無払いはオンラインオークション運営者の永遠の悩みの種だからな。Stripe's payment intents with capture_method: manual is 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 in us-east-1 and Supabase in eu-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.
- 入札のオプティミスティック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 imperceptible unless the 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 Broadcast feature which is more efficient for high fan-out scenarios.
Supabase Realtimeを使用するべき、それともAblyのような専用サービスを使用するべき?
ほとんどのプロジェクトではSupabase Realtimeで十分であり、データがすでにSupabaseにあるため統合がずっとシンプルだ。グローバルで50ms以下のレイテンシーが必要か、数百万の同時接続を持つものを構築しているのでなければ、Aablyや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 an online/offline event 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は個別にレート制限するのが少し難しい(ミドルウェアレベルではなく、アクション単位ではなく、ミドルウェアレベルでレート制限を適用することになる)。本番運用のオークションサイトなら、Upstash Redisレート制限をミドルウェアに追加して、ActionsかAPIルートかに関わらず、単一ユーザーが入札リクエストをスパムするのを防ぐ。/api/bid route. 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 add Upstash Redis rate limiting in middleware to prevent a single user from spamming bid requests regardless of whether you use Actions or API routes.
同一時刻に同じ金額の入札が2つ入った場合、どのように処理すればいいのか?
place_bid Postgres関数のFOR UPDATEロックは同時入札をシリアライズするため、技術的にはデータベースレベルではタイが起こりえない。1つは成功し、もう1つは「入札が低すぎる」レスポンスで失敗する(両方ともcurrent_bidと等しく、チェックがp_amount <= v_lot.current_bidだから)。先着順。それは標準的なオークション慣行で、ほとんどの入札者はそれを理解している。FOR UPDATE lock in the place_bid Postgres 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 to current_bid and the check is p_amount <= v_lot.current_bid). First in, first served. That's standard auction practice and most bidders understand it.
---
Bath のアンティーク商人は、参考までに言うと、月1回のオークションをオンラインで2年以上開催している。ある土曜夜のピーク時には同時接続入札者が84人に達した――村全体がジョージアン・シルバーの争点となったロットが予想価格の3倍で落札される様子を見守っていたほどだ。Supabase は揺るがなかった。Next.js も揺るがなかった。壊れたのは彼のWi-Fiだけで、それはショップフロアから運営していたからだ。
リアルタイム処理は事前に完全に設計することは難しいが、スキーマがしっかりしていて、原子的な入札関数が機能していれば、残りはほぼ配管作業だ。基礎をしっかり作っておけば、真夜中にレース条件をデバッグするのではなく、カウントダウンアニメーション、「初値、再値」UX といった楽しい部分に時間を使える。
