Build This Now
Build This Now
クロード・コードとは何か?Claude Code のインストールClaude Code ネイティブインストーラーClaude Code で最初のプロジェクトを作る
Failed Payment RecoveryLocalization and i18nPDF InvoicesReferral ProgramTwo-Factor AuthNext.js DevTools MCPNext.js Agent SetupSubscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email Sequencesv2.1.122 Release NotesClaude Code の Dynamic Workflows:実際のコードベースで 1,000 個の subagents を動かす方法Claude Code ベストプラクティスClaude Opus 4.7 ベストプラクティスVPS上でのClaude CodeGit 統合Claude Code レビューClaude Code WorktreesClaude CodeリモートコントロールClaude Code ChannelsChannels、Routines、Teleport、DispatchClaude Code スケジュールタスクClaude Code権限管理Claude Code オートモードClaude Code で Stripe 決済を組み込むフィードバックループTodoワークフローClaude Code タスク管理プロジェクトテンプレートClaude Code の料金とトークン使用量Claude Code の料金:実際にいくら払うことになるのかClaude Code Ultra Review 完全ガイドClaude Code で Next.js アプリを作るSupabase DatabaseVercel DeepsecTest-Driven DevelopmentClaude Code で SaaS の MVP を作る方法Claude Code で認証を追加する(Supabase Auth)Claude Code でトランザクションメールを追加する(Resend + React Email)Claude Code で型安全な API を作る(oRPC + Zod)File UploadsBackground Jobs (Inngest)Admin DashboardFull-Text SearchClaude Agent SDKKiro Migration GuideClaude Research AgentLeave Grok BuildRoute Subagent ModelsClaude Monorepo SetupCI Repair AgentVisual Regression TestsAgent Cost DashboardCursor Migration Guideエージェント型コマース:AI エージェントが支払えるアプリの作り方1M ContextUser API KeysAudit LogsCSV Import PipelineDatabase MigrationsProduction Error TrackingFeature FlagsGitHub ActionsHeadless ModeMax Plan vs APICaching and RevalidationIn-App NotificationsOutbound WebhooksPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based Billing2026年、Claude Code で SaaS を作るといくらかかるかParallel AI AgentsCoding Agent Injection
speedy_devvkoen_salo
Blog/Handbook/Workflow/Supabase Auth

Claude Code で認証を追加する(Supabase Auth)

Claude Code と Supabase Auth を使って、Next.js 16 アプリにメール/パスワードのサインアップ、Google OAuth、magic link、保護されたルート、セッション管理を追加する方法。

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →
speedy_devvkoen_salo
speedy_devvWritten by speedy_devvPublished Jul 14, 20269 min readHandbook hubWorkflow index

Supabase Auth は、セッション・JWT・OAuth がすでに組み込まれたホスト型のユーザーシステムを提供します。パスワードのハッシュ化ルーチンやトークンの更新ループを自分で書くことはありません。あなたが書くのは、それを Next.js に繋ぐ配管です。2 つの Supabase クライアント、proxy.ts でのセッション更新ステップ、そしてそれを呼ぶフォームと Server Action です。正しいパターンを与えれば、Claude Code はそのすべてを正しく生成できます。この記事では、動くコードとともにそれを順に見ていきます。

Supabase Auth で得られるもの

Supabase Auth は、あなたの Postgres データベースの上で動きます。ユーザーは auth.users というスキーマに存在しますが、あなたがここを直接クエリすることはありません。すべてのサインアップ、ログイン、OAuth フローは同じ形で終わります。Supabase が JWT を発行し、@supabase/ssr がそれを cookie に保存するので、あなたの Next.js サーバーはリクエストごとにそれを読めます。

メール/パスワード認証、magic link(メールで届く 1 回きりのリンクによるパスワードレスなサインイン)、そして 30 以上のプロバイダーに対応した OAuth が最初から使えます。この記事では OAuth の例として Google を扱います。多くのビルダーが最初に手を伸ばすのがこれだからです。Google が動けば、他のどのプロバイダーでもパターンは同じです。

パッケージをインストールする

パッケージは 2 つ、どちらも Supabase 製です。@supabase/supabase-js がコアのクライアント、@supabase/ssr は Next.js のようなサーバーレンダリングのフレームワーク向けに作られた、cookie ベースのセッション管理を追加します。

npm install @supabase/supabase-js @supabase/ssr

Supabase プロジェクトのダッシュボード、Project Settings > API の下から、環境変数も 2 つ必要です。

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

anon キーはブラウザに公開しても安全です。アクセス制御は、このキーを隠すことではなく、テーブルの row-level security ポリシーから来ます。

2 つのクライアント:ブラウザとサーバー

Next.js での Supabase Auth には、別々のクライアントが 2 つ必要です。cookie の扱いが両サイドで異なるからです。ブラウザクライアントは document.cookie を通じて cookie を読み書きします。サーバークライアントは、Next.js が渡してくれるリクエストとレスポンスのオブジェクトを通じて読み書きします。

まずブラウザクライアントを Claude に作らせましょう。短いものです。

// utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

サーバークライアントは Server Components、Server Actions、Route Handlers の中で使います。Next.js 16 では、next/headers の cookies() が Promise を返すので、await が必要です。

// utils/supabase/server.ts
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_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Called from a Server Component that can't set cookies.
            // proxy.ts refreshes the session instead, so this is safe to ignore.
          }
        },
      },
    }
  )
}

この try/catch は飾りではありません。Server Component は cookie を読むことはできても、まったく設定できません。実際にセッションを生かし続けるのは proxy.ts のセッション更新ステップ(次のセクション)なので、ここで Server Component が cookie を設定できないのはバグではなく想定どおりの動作です。

メール/パスワードのサインアップとログイン

フォームの送信は Server Action が処理します。サインアップもログインも同じ形です。FormData からフィールドを取り出し、Supabase のメソッドを呼び、成功したらリダイレクトします。

// app/login/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

export async function signup(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const { error } = await supabase.auth.signUp({ email, password })

  if (error) {
    redirect(`/login?error=${encodeURIComponent(error.message)}`)
  }

  revalidatePath('/', 'layout')
  redirect('/check-email')
}

export async function login(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const { error } = await supabase.auth.signInWithPassword({ email, password })

  if (error) {
    redirect(`/login?error=${encodeURIComponent(error.message)}`)
  }

  revalidatePath('/', 'layout')
  redirect('/dashboard')
}

export async function signOut() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  revalidatePath('/', 'layout')
  redirect('/login')
}

デフォルトでは、Supabase は新しいアカウントがログインできるようになる前にメール確認を要求します。signup がダッシュボードへ直行せず /check-email ページへリダイレクトするのはそのためです。ローカルテストのために Supabase ダッシュボードの Authentication > Providers で確認をオフにできますが、本番ではオンのままにしておきましょう。

フォーム自体はバリデーションの状態を表示できるよう Client Component ですが、送信アクションはサーバー上で走ります。

// app/login/page.tsx
import { login, signup } from './actions'

export default function LoginPage({
  searchParams,
}: {
  searchParams: Promise<{ error?: string }>
}) {
  return (
    <form className="max-w-sm mx-auto py-12 space-y-4">
      <h1 className="text-2xl font-bold">Log in</h1>
      <input
        id="email"
        name="email"
        type="email"
        required
        placeholder="you@example.com"
        className="w-full border rounded px-3 py-2"
      />
      <input
        id="password"
        name="password"
        type="password"
        required
        placeholder="Password"
        className="w-full border rounded px-3 py-2"
      />
      <div className="flex gap-2">
        <button formAction={login} className="flex-1 bg-black text-white rounded py-2">
          Log in
        </button>
        <button formAction={signup} className="flex-1 border rounded py-2">
          Sign up
        </button>
      </div>
    </form>
  )
}

searchParams.error の表示を Claude に追加させれば、エラーメッセージがタダで手に入ります。Next.js 16 では searchParams は Promise なので、Server Component の中でその値を直接使うなら、読むときにやはり await が必要です。

proxy.ts でのセッション更新

Next.js 16 は middleware.ts を proxy.ts にリネームし、エクスポートする関数も middleware から proxy に変わりました。プロジェクトの指示がないと Claude はいまだにデフォルトで middleware.ts を書くので、CLAUDE.md に 1 行足しておきましょう。「Middleware logic goes in proxy.ts, exported as proxy, not middleware.ts.」

更新ロジックは、テストと再利用がしやすいよう、まずヘルパーファイルに置きます。

// utils/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const {
    data: { user },
  } = await supabase.auth.getUser()

  const protectedPaths = ['/dashboard', '/account']
  const isProtected = protectedPaths.some((path) => request.nextUrl.pathname.startsWith(path))

  if (!user && isProtected) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

ここで getUser() を呼ぶと、2 つのことが同時に起きます。トークンを Supabase のサーバーに対して検証し、そしてトークンが期限切れに近ければセッション cookie を更新します。これが保護されたページだけでなくリクエストごとに走るのはそのためです。期限切れのセッションは、切れたあとではなく切れる前に更新する必要があるのです。

ルートの proxy.ts ファイルは、ただヘルパーを呼び、どのパスで走るかを定義するだけです。

// proxy.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/proxy'

export async function proxy(request: NextRequest) {
  return updateSession(request)
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

matcher は静的アセットと画像を除外するので、proxy が favicon のリクエストごとにセッションチェックを走らせることはありません。それでもすべてのページと API ルートでは走るので、アプリ全体でセッションが新鮮に保たれます。

ルートを保護する(多層防御)

上の proxy のリダイレクトはたいていのケースをカバーしますが、これを唯一のチェックだと考えてはいけません。matcher パターンは微妙に間違えやすく、保護したかったルートを黙って除外してしまうと、そのルートは今や無防備です。ページの中で改めて確認しましょう。

// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  return <>{children}</>
}

これは保護されたページの読み込みごとに Supabase への往復が 1 回余分にかかります。正規表現の matcher を唯一の認可境界に頼らずに済むなら、妥当なトレードオフです。この 2 つのチェックの下にある本当の最後の砦は、テーブルの row-level security です(後述)。攻撃者が回避できない層だからです。

Google OAuth

コードを書く前に、Supabase ダッシュボードの Authentication > Providers で Google プロバイダーをオンにします。Google Cloud Console から Client ID と Client Secret が必要で、Google に受け入れさせるリダイレクト URI は、そのページで Supabase が表示する https://your-project-ref.supabase.co/auth/v1/callback の形のものです。

サインインアクションは Supabase から URL を要求し、ブラウザをそこへリダイレクトします。

// app/login/actions.ts (add to the same file)

export async function signInWithGoogle() {
  const supabase = await createClient()

  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: {
      redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
    },
  })

  if (error || !data.url) {
    redirect('/login?error=Could not sign in with Google')
  }

  redirect(data.url)
}

NEXT_PUBLIC_SITE_URL には、実際にデプロイした URL(開発時は http://localhost:3000)を設定しておく必要があります。そうすればリダイレクトが Supabase のデフォルトではなく、あなたのアプリに戻ってきます。

<form>
  <button formAction={signInWithGoogle} className="w-full border rounded py-2">
    Continue with Google
  </button>
</form>

Magic link

magic link は、メールで送られる 1 回きりのサインインリンクです。設定したり覚えたりするパスワードはありません。Supabase は内部でこれをパスワードレスの OTP フローと呼び、OAuth と同じコールバックルートを使います。

// app/login/actions.ts (add to the same file)

export async function sendMagicLink(formData: FormData) {
  const supabase = await createClient()
  const email = formData.get('email') as string

  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
    },
  })

  if (error) {
    redirect(`/login?error=${encodeURIComponent(error.message)}`)
  }

  redirect('/check-email')
}

必要なのはメールフィールドだけでパスワードは要らないので、これは専用の小さなフォームに繋ぎましょう。

<form className="space-y-2">
  <input name="email" type="email" required placeholder="you@example.com" className="w-full border rounded px-3 py-2" />
  <button formAction={sendMagicLink} className="w-full border rounded py-2">
    Send magic link
  </button>
</form>

両方のフローが共有するコールバックルート

Google OAuth と magic link は、どちらも Supabase がブラウザを code クエリパラメータ付きであなたのアプリに戻すことで終わります。1 つの Route Handler が、その code をセッションと交換します。

// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/dashboard'

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)

    if (!error) {
      return NextResponse.redirect(`${origin}${next}`)
    }
  }

  return NextResponse.redirect(`${origin}/login?error=Could not authenticate`)
}

exchangeCodeForSession が呼ばれるのはここだけです。すべての OAuth プロバイダーとすべての magic link がここを指すので、後で 2 つ目のプロバイダーを追加しても、新しいコールバックルートは要りません。同じ /auth/callback へリダイレクトする新しいサインインアクションを追加するだけです。

auth.uid() に紐づけた row-level security

上の認証フローだけでは、ログイン済みユーザーがデータベースで何を見られるかは何も制限されません。それは Postgres の仕事で、row-level security(RLS)を通じて強制されます。よくあるパターンは、誰かがサインアップするたびに自動で 1 行が作られる profiles テーブルです。auth.users に対する Postgres のトリガーを使います。

create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  email text,
  display_name text,
  created_at timestamptz default now()
);

create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, email)
  values (new.id, new.email);
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

テーブルができたら RLS をオンにし、行の所有者をログイン中のユーザーの ID と照合するポリシーを書きます。

alter table public.profiles enable row level security;

create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = id );

create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );

auth.uid() は、@supabase/ssr がすべてのリクエストに付ける検証済みの JWT からユーザー ID を読み取ります。このテーブルについては、アプリケーションコードに別途の認可チェックを書く必要はありません。行が呼び出し元のものでなければ、Postgres がそのクエリをきっぱり拒否します。これは proxy.ts のリダイレクトや上のレイアウトチェックを支えるのと同じ保証ですが、ルートチェックの抜け漏れでスキップされることがない点が違います。RLS の全体像(INSERT/DELETE ポリシー、ビュー、そして security_invoker の落とし穴)については、Claude Code With Supabase を参照してください。

繋ぎ終えたら、このフロー全体を一度端から端まで実行するよう Claude に頼みましょう。新しいメールでサインアップし、確認し、ログインし、Google でサインインし、magic link を要求し、ログアウト状態で保護されたページに当たってリダイレクトを確認する。これこそが本当に大事なテストで、クリーンな型チェックだけでは足りません。

ここに出てくるすべて(2 つのクライアント、proxy.ts での更新、コールバックルート、RLS ポリシーの形)は、Claude Code が一度は正しく書けるものですが、どこかにパターンを保存しておかない限り、新しいプロジェクトごとにゼロから導出し直されます。$29 の Code Kit はこれをすでに繋いだ状態で出荷します。保護されたページ、Google OAuth、最初のマイグレーションから全テーブルに RLS。だから新しいプロジェクトは、この配管を作り直すのではなく、その先から始まります。これは Claude Code の上に載る一度きりの harness で、サブスクリプションではありません。そして Claude Code 自体は、その下でやはり有料の Anthropic プランを必要とします。

Posted by @speedy_devv

Continue in Workflow

  • エージェント型コマース:AI エージェントが支払えるアプリの作り方
    2026年のエージェント型コマースをわかりやすく解説するガイド。x402、ACP、Machine Payments Protocol が何をするのか、そして AI エージェントが購入できる有料 API を週末で出荷するための手順を紹介します。
  • Claude Code ベストプラクティス
    Claude Codeで成果を出すエンジニアを分ける5つの習慣: PRD、モジュラーなCLAUDE.mdのルール、カスタムスラッシュコマンド、/clearリセット、そしてシステム進化の思考法。
  • Claude Code オートモード
    2つ目の Sonnet モデルが、Claude Code のすべてのツール呼び出しを実行前に審査します。オートモードがブロックするもの・許可するもの、そして settings.json に追加される許可ルールについて解説します。
  • Channels、Routines、Teleport、Dispatch
    Anthropic が2026年3月と4月に出荷した4つの Claude Code 機能。これらは CLI を、スマホ・ウェブ・デスクトップをまたぐイベント駆動の調整レイヤーに変えます。
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Best SaaS Boilerplate 2026: The Honest Comparison
    An honest 2026 roundup of the best SaaS boilerplates and starter kits (ShipFast, Makerkit, Supastarter, SaaS Pegasus, Divjoy, open source), with real pricing, stacks, and the trade-off nobody mentions: a boilerplate still leaves you coding.
  • Claude Code 変更履歴
    v0.2ベータから2026年3月までのClaude Codeのリリースノート。ベアモード、チャンネル権限リレー、OAuthの修正、すべての破壊的変更を網羅。
  • What It Really Costs to Build a SaaS MVP in 2026
    A buyer's cost breakdown for building a SaaS MVP in 2026. Real freelancer, agency, no-code, AI-tool, and DIY numbers with citations, plus where the money actually goes.
  • コードを生み出すプロンプトテンプレート
    コードを出荷する10のプロンプトレシピ: フルスタックスキャフォールディング、API、スキーマ、テスト、リファクタリング、デバッグ、レビュー、CI。それぞれの失敗パターンも紹介。

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →
speedy_devvkoen_salo

Claude Code で SaaS の MVP を作る方法

週末のビルドログ。Next.js 16 アプリの土台を作り、Supabase の認証と Postgres を追加し、Stripe の課金を繋ぎ込み、Vercel にデプロイする。すべて Claude Code で。

Claude Code でトランザクションメールを追加する(Resend + React Email)

Resend と React Email を使って、Next.js 16 アプリでウェルカム・レシート・パスワードリセットのメールを作る方法。テンプレートと送信ロジックは Claude Code が書きます。

On this page

Supabase Auth で得られるもの
パッケージをインストールする
2 つのクライアント:ブラウザとサーバー
メール/パスワードのサインアップとログイン
proxy.ts でのセッション更新
ルートを保護する(多層防御)
Google OAuth
Magic link
両方のフローが共有するコールバックルート
auth.uid() に紐づけた row-level security

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →