Supabase と Next.js をつなぐ作業は、ライブラリを入れてcreateClientを呼ぶだけでは終わりません。難所は、認証トークンをどこで更新し、誰がクッキーへ書くのかという1点に集まります。App Router ではサーバーコンポーネントがクッキーを書けず、更新役を別に置く構成が前提で、その置き場所の名前が Next.js 16 で変わりました。この記事では 16.3系(2026年8月時点で 16.3.3)と @supabase/ssr 0.12系を前提に、クライアントの作り分け、セッション更新、書き込み、権限設計、キャッシュ事故の防ぎ方を実装順に並べます。
まとめ:Supabase Next.js連携で先に決める4点
この連携の骨格は「クッキーに置いたトークンを、リクエストの入口で1回だけ更新する」ことです。ブラウザ側とサーバー側で別のクライアントを作り、更新はネットワーク境界に置いた proxy.ts が担う。サーバーコンポーネントは読むだけ、という役割分担を崩さないでください。
着手前に決める点は4つあります。第一に、環境変数を旧来の anon key で通すのか、新しい publishable key へ移すのか。第二に、ページの保護を getClaims() で行うと決め切ること。第三に、認可の確認を入口だけに置かず Server Actions の内側でも行うこと。第四がキャッシュとの兼ね合いで、use cache のスコープ内でクッキーは読めず、ISR や CDN 配下では応答の Set-Cookie が別の利用者へ配られる事故が起こりえます。
@supabase/ssrで作る2種類のクライアントと配置場所の決め方
まず土台を作ります。導入するパッケージは @supabase/supabase-js と @supabase/ssr の2つで、旧来の @supabase/auth-helpers-nextjs は使いません。
ブラウザ用とサーバー用で分けるクライアント生成コードの置き場所
公式サンプルは lib/supabase ディレクトリを切り、種別ごとにファイルを分けます。ブラウザで走るクライアントコンポーネント向けが client.ts の createBrowserClient、サーバーコンポーネントと Server Actions、Route Handler 向けが server.ts の createServerClient です。
生成関数をモジュールのトップレベルで1回だけ呼んで共有しないでください。公式サンプルのコメントは Fluid compute のような実行環境を挙げて「グローバル変数に置かず、リクエストごとに新しく作れ」と明示しています。使い回すと別の利用者のトークンを掴む恐れがあるためです。
サーバー側の生成関数が非同期になる理由は Next.js 側の仕様です。next/headers の cookies() は Promise を返すため、await してからストアを渡します。App Routerのファイル規約を整理した記事で扱う app ディレクトリへ、この lib を横付けする形です。
サーバークライアントの実装は、公式サンプルをほぼそのまま採るのが安全です。
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet, _headers) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component から呼ばれた場合は無視する
}
},
},
}
)
}
try で例外を捨てている箇所が、初見でいちばん気持ち悪く映ります。サーバーコンポーネントのレンダリング中はクッキーへ書き込めず、cookieStore.set が例外を投げるためです。書けないのが正常なので握り潰してよい。ただし「入口の proxy.ts が確実に更新している」前提の上に成り立ちます。入口が無ければ、期限切れの瞬間から利用者が突然ログアウトされます。
setAll の第2引数に headers が入る点は 0.12系で押さえておきたい差分です。サーバーコンポーネント側では使わないため _headers として捨てますが、入口側では受け取った値を応答ヘッダへ反映させます。ここがキャッシュ事故の防止に効いてきます。
環境変数の名前をpublishable keyへ切り替える手順と注意
公式ドキュメントが現在示す変数名は NEXT_PUBLIC_SUPABASE_URL と NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY の2つです。かつての NEXT_PUBLIC_SUPABASE_ANON_KEY ではありません。
キー体系は新旧2系統が並走しています。新しい方は sb_publishable_ で始まる公開用と sb_secret_ で始まる秘匿用。旧来の JWT 形式の anon と service_role は、公式ドキュメントに「2026年末までに非推奨化される」と書かれています。新規に組むなら新体系で始めてください。
| キー | 接頭辞 | 置き場所 | RLS |
|---|---|---|---|
| publishable | sb_publishable_ | ブラウザ可 | 効く |
| secret | sb_secret_ | サーバーのみ | 迂回する |
| anon(旧) | JWT形式 | ブラウザ可 | 効く |
| service_role(旧) | JWT形式 | サーバーのみ | 迂回する |
公式ドキュメントは publishable key を「ウェブページ、モバイル、ソースコードに露出させて差し支えない」と説明しています。この鍵が単体では何の権限も持たず、守りを行レベルセキュリティに委ねているためです。裏を返せば、ポリシーを書き忘れたテーブルは公開状態になります。
Next.js 16でmiddlewareがproxyへ変わったセッション更新
次が連携の心臓部です。Next.js 16 で middleware.ts が非推奨となり、proxy.ts へ改称されました。Supabase の公式ガイドもすでに Proxy 表記へ更新されています。
proxy.tsのupdateSessionが担うトークン更新の処理順
改称の理由は、Express のミドルウェアと混同されやすかったことにあります。「アプリの前段にあるネットワーク境界」という性質を名前へ反映させた、という説明です。既存コードには npx @next/codemod@canary middleware-to-proxy . が用意され、ファイル名と関数名が置き換わるだけでロジックは変わりません。
ランタイムには実質的な変更があります。Proxy は Node.js ランタイムが既定で、runtime の設定項目は使えず、指定するとエラーになります。
ルート直下に置く proxy.ts は薄く保ち、実処理は lib/supabase/proxy.ts の updateSession へ寄せる構成が公式サンプルの形です。
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'
export async function proxy(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
updateSession の内側では、リクエストのクッキーを読むクライアントを作り、setAll で3方向へ書き戻します。リクエスト側のクッキー、応答側のクッキー、そして応答ヘッダです。
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
)
},
リクエスト側へ書くのは、この後で走るサーバーコンポーネントに新しいトークンを渡すためです。応答側へ書くのはブラウザの古いトークンを置き換えるため。応答ヘッダへ書くのはキャッシュ制御を伝えるためで、0.12系で headers が渡るようになった意味はここにあります。
公式サンプルには強い注意書きが2つ入っています。1つは createServerClient と getClaims() の間にコードを挟むなという指示。もう1つは、返す応答オブジェクトをそのまま返せという指示です。別の NextResponse を返す場合は、request を渡してクッキーを丸ごと写してから加工してください。崩すとブラウザとサーバーの状態がずれ、セッションが早期に切れます。
getClaimsとgetSessionのどちらでページを守るかの線引き
認証済みかどうかの判定に使える関数は3つあり、性質がまったく違います。
| 関数 | 検証の中身 | 用途 |
|---|---|---|
| getClaims | JWTの署名を検証 | ページとデータの保護 |
| getUser | Auth基盤へ問い合わせ | 最新のユーザー情報 |
| getSession | 保存領域から読むだけ | トークン自体の取得 |
保護に使うのは getClaims() です。新規プロジェクトの既定である非対称署名鍵の構成なら、WebCrypto API とキャッシュ済みの公開鍵一覧でローカルに署名を検証します。対称鍵の構成では、検証のためだけに getUser を呼ぶ挙動になる。返るクレームは JWT を復号した結果で、毎回署名が確かめられています。JWTの構造と署名検証の仕組みを扱った記事を読むと、この違いが掴みやすいでしょう。
公式ドキュメントは getSession() について「サーバーコードの内側で信頼するな」と書いています。保存領域から読むだけでトークンの再検証を保証せず、クッキーのように利用者が触れる場所にある以上、埋め込まれたユーザー情報は認可判断に使えません。生のアクセストークンを別サービスへ転送したいときだけ使う関数です。セッションIDの発行から失効までを整理した記事と同じで、信じてよいのは検証を通った値だけです。
matcherの書き方とServer Functionが素通りする落とし穴
matcher を書かないと、Proxy は静的ファイルや画像処理の経路まで含めて全リクエストで走ります。認証処理を入れたつもりが CSS や画像の読み込みまで止める、という壊し方になるため、除外パターンは必ず添えてください。
より見落としやすい罠がその先にあります。Next.js のドキュメントは、Server Function がルートとして独立せず「使われているルートへの POST として扱われる」と説明しています。matcher で除外したパスでは、そのパス上の Server Function 呼び出しも Proxy を通りません。matcher の書き換えやルート移動だけで、入口の網が静かに外れます。
ドキュメントの結論は明快です。各 Server Function の内側で認証と認可を確かめること。この二重化を省くと、開発中は動いていた保護が、パス構成を触った日に無言で消えます。
Server ComponentとServer ActionsからDBへ触る書き分け
入口が整ったら、読み書きする側の作法を固めます。一覧や詳細の表示はReact Server Componentsの使い分けを解説した記事のとおりサーバー側で読み、クッキーの更新は入口へ任せる。この層は応答へ Set-Cookie を載せられないため、読み取り専用の顔をしていればよく、前節の例外の握り潰しも設計どおりの挙動です。
Server ActionsからのINSERTとrevalidateの組み合わせ
書き込みは Server Actions に寄せます。フォームの送信先として関数を直接渡せるため、API エンドポイントを別に立てる手間が消えます。
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
export async function addTask(formData: FormData) {
const supabase = await createClient()
const { data } = await supabase.auth.getClaims()
if (!data?.claims) {
throw new Error('unauthenticated')
}
await supabase.from('tasks').insert({
title: String(formData.get('title')),
user_id: data.claims.sub,
})
revalidatePath('/tasks')
}
冒頭で getClaims() を呼んでいるのが前節の二重化です。入口を通っている想定でも、ここで確かめる。user_id にクライアントから受け取った値を入れず、検証済みクレームの sub を使う点も外せません。フォームの hidden フィールドは書き換えられます。
末尾の revalidatePath を忘れると、書き込みは成功しているのに画面が古いまま残ります。Next.jsのキャッシュの保存先と再検証APIの使い分けを扱った記事で整理されている通り、どの層のキャッシュを落とすのかを意識して選んでください。
Route Handlerとクライアント側から呼ぶ場面の使い分け
Route Handler が要るのは、外部から叩かれる入口が必要な場面です。OAuth のコールバック、Webhook の受け口、モバイル向けの JSON API がここに当たります。フォーム送信の受け皿として新設する必要はありません。
クライアントコンポーネントから直接呼ぶのは、リアルタイム購読のように接続を張り続ける処理に限る線引きが扱いやすい。購読チャネルの選び方と同時接続の上限はSupabase Realtimeの実装を扱った記事にあります。表示用のデータ取得をクライアント側へ持っていくと、ブラウザ向けの鍵で叩く経路が増えるだけです。
RLS前提の権限設計とsecret keyを持ち込まない構成
ここが受託開発で最も事故が起きる領域です。Supabase は権限の判断をデータベース側の行レベルセキュリティへ寄せた設計で、アプリ側のコードだけ読んでいても安全性が判定できません。
publishable keyで動かしanonとauthenticatedで絞る
publishable key で発行された接続は、未ログインなら anon、ログイン済みなら authenticated というデータベースロールで動きます。どちらも行レベルセキュリティのポリシーに従うため、テーブルごとに書いた分だけ見え方が決まる仕組みです。行単位アクセス制御の仕組みを解説した記事の一般論と土台は同じで、Supabase では認証情報が JWT 経由で渡る点が違います。
ポリシーを後回しにすると、テーブルを作った瞬間に公開状態のまま画面が動き、動作確認が通ってしまう。そのまま本番へ出る経路が一番危ない。テーブル追加とポリシー追加を同じ変更単位に縛ってください。書き方と有効化漏れの検知手順はSupabase RLSの設定手順とAuth連携ポリシー設計の記事に分けて置いています。
secret keyがRLSを迂回する仕組みと使ってよい範囲
secret key は BYPASSRLS 属性を持つロールで動き、すべてのポリシーを飛び越えます。公式ドキュメントは、ブラウザ、公開文書、ソースコード、チャットやメール、URL のクエリ文字列への持ち出しを禁じています。ブラウザから使った場合は常に HTTP 401 を返す設計で、誤用が実行時に止まる作りです。鍵の種類と取得場所はSupabase APIキーの管理を扱った記事で確認できます。
サーバー側なら安全、とも言えません。Server Actions とサーバーコンポーネントは同じプロセスで動くため、secret key を共通クライアントへ入れると、画面表示の問い合わせまでポリシー無しで通ります。ポリシーの効きを検証する手段が消えるので、共通クライアントは publishable key で組み、secret key は利用者の権限で動かない処理へ限定してください。
認可をproxyだけに任せずServer Actions側でも確かめる二重化
入口とデータアクセス層の二重化は、Next.js 公式がデータセキュリティの指針として案内している形でもあります。実務では次の3段で組むと漏れが出にくくなります。
- 入口の
proxy.tsで未認証を弾き、ログイン画面へ飛ばす - Server Actions と Route Handler の先頭で
getClaims()を確認する - テーブルのポリシーで、他人の行に触れない状態を保証する
3段目まで書くと、1段目と2段目の実装漏れが情報漏洩へ直結しません。逆に3段目だけでも足りない。ポリシーは行の見え方を決めるだけで、ログインしていない利用者を管理画面のURLから追い出してはくれないためです。この構成をゼロから組む案件では工数が大きく振れます。設計段階から相談したい場合は業務用・Webアプリ開発の窓口へ状況を共有してください。
use cacheとISRでセッションが混ざる事故を避ける設定
最後がキャッシュとの噛み合わせです。Next.js 16 は Cache Components を前面に出したため、この連携で新しく踏む地雷が増えました。
use cache を付けた関数やコンポーネントの内側では、cookies()、headers()、searchParams といったリクエスト固有のAPIを読めません。Supabase のサーバークライアントは cookies() を読むため、キャッシュ対象のスコープで createClient() を呼んだ時点で next-request-in-use-cache エラーになります。
厄介なのは失敗の見え方です。ドキュメントは、この制約が呼び出し階層をたどって効くと明記しています。キャッシュ対象の関数が呼ぶヘルパーがクッキーを読んだ場合も同じように失敗し、動的レンダリングのルートではルートが走った時点で初めて表面化します。next build が通って next start で落ちる、という順序になるため、ビルドが緑だから大丈夫という判断が成立しません。
回避策は素直で、キャッシュ対象の外でクッキーを読み、値を引数として渡すことです。利用者ごとに違う内容をキャッシュしたい場合は、クレームから取った利用者IDを引数にすればキャッシュキーへ含まれる。引数で渡せないときの逃げ道が use cache: private ですが、ドキュメント自身が「ごく稀な場合」と位置づけています。Next.js 16の主要機能を概観した記事で変更点を掴んでから設計へ入ってください。
ISRやCDN配下でSet-Cookieが共有される事故の防ぎ方
Supabase 公式ガイドは、キャッシュに関する注意を独立した節で挙げています。ISR を使うか CDN の背後に置く構成では、HTTP 応答のキャッシュが原因で「ある利用者が別人のセッションを受け取る」事態が起こりえる。トークン更新時に新しい値が Set-Cookie で応答へ載るため、その応答が別の利用者へ配られると、間違った人としてログインした状態になります。
だから setAll の第2引数で受け取ったヘッダを応答へ反映させる実装が要るのです。認証状態に依存する応答をキャッシュさせないための情報が、この headers に載ってきます。公式サンプルどおりに Object.entries(headers) を回して転記しておけば、既定の構成で守られます。
手を加える場合は、認証を伴う画面と静的に配れる画面を分けて考えてください。SSG・ISR・SSR・CSRの違いとApp Routerでの選び方を扱った記事の分類で言えば、ログイン後の画面を ISR の対象へ入れる設計自体を避けるのが安全側です。
この連携を見送りRoute Handler経由に寄せてよい場面
ここまでの構成を全案件へ入れる必要はありません。見送ってよい条件を挙げます。
1つ目は、ログインを伴う画面が存在しない場合です。公開情報を読むだけなら、サーバーコンポーネントから publishable key で読めば済み、入口もクッキーの往復も要りません。2つ目は、認証基盤が既に別にある場合。寄せ替えの判断はこの記事の範囲を超える移行作業になります。製品選定の段階ならSupabaseとFirebaseの違いを比較した記事で全体像を確かめてから決めてください。
3つ目は、既存の重い業務ロジックがサーバー側にある場合です。データベースへ直接触る経路を画面から生やすより、Route Handler か既存APIの背後に閉じ込めたほうが、権限判断の場所が1つに収まります。ポリシーで表現しきれない認可要件を抱えているなら、この形へ寄せるほうが保守しやすいでしょう。
よくある質問
@supabase/auth-helpers-nextjsから移行する必要はありますか?
あります。現行の公式ガイドは @supabase/ssr を前提に書かれており、Auth Helpers からの移行手順も専用ページとして用意されています。App Router との組み合わせでセッションが保持されない不具合の報告も多く、新規実装で選ぶ理由はありません。
middleware.tsのままでも動きますか?
非推奨で将来削除される予定ですが、16 系では動きます。npx @next/codemod@canary middleware-to-proxy . でファイル名と関数名が置き換わるだけなので、先に済ませておくほうが安全でしょう。Edge ランタイムを使う事情がある場合のみ残す形です。
getUserではなくgetClaimsを使うと何が変わりますか?
ネットワーク呼び出しの有無が変わります。非対称署名鍵の構成なら getClaims() はローカルで署名を検証するため、Auth 基盤への往復が消えます。最新のユーザー情報そのものが必要な場面だけ getUser を使う、という切り分けが公式の推奨です。検証に使う公開鍵の取得元と JWKS キャッシュの挙動はSupabase Authの署名鍵とgetClaims()検証を扱った記事で確認できます。
ローカル環境でNext.jsとSupabaseをつなぐには何が必要ですか?
Supabase CLI でローカルスタックを立て、環境変数の URL とキーをローカル用の値へ差し替えるだけです。手順とマイグレーション運用はSupabase CLIとローカル開発環境の記事で扱っています。本番とプレビューへ同じ値を配る側の設計はSupabase Vercelデプロイ連携の記事にまとめています。
secret keyをServer Actionsで使ってはいけないのですか?
画面表示や利用者の操作を代行する処理では使わないでください。ポリシーを飛び越えるため、認可の検証手段が消えます。管理バッチや Webhook のように利用者の権限で動かない処理に限り、共通クライアントとは別に生成して使う形へ分けてください。
関連記事
- SupabaseとFirebaseの違いを徹底比較|料金・認証・どっちを選ぶか:製品選定の段階で読む全体像を整理しています。
- Supabase RLSの設定手順とAuth連携ポリシー設計|有効化漏れの検知まで:ポリシー本体の書き方を扱います。
- Supabase CLIとローカル開発環境:db diffで進めるマイグレーションとCI:ローカル接続先と型生成の手順を解説。
- Supabase Edge Functionsとは:Deno実行環境とデプロイ・シークレット・課金単位:サーバー側処理を関数へ寄せる選択肢です。
- App Routerとは?Next.jsのファイル規約・動的ルーティング・Pages Routerとの違い:配置の前提となる規約を確かめられます。