栽培管理アプリを作ってます⑮-2: AuthのデモログインがServer Componentで動かない理由と解決策

Next.js + Supabaseで農業向け栽培管理アプリを開発しています。
今回はデモ環境を作ってワンクリックログインを実装した話です。

目次

デモ環境について

自分が実際に農業をしながら、作ったアプリにポチポチとデータを入れています。圃場の登録、作業記録、収穫記録など、日々の農作業データをそのまま本番データとして蓄積しています。

デモ環境には、その本運用データを定期的に移しています。つまりデモにはリアルな農業データが入っている状態です。

出荷アプリのデモ環境は、訪問者がメールアドレスやパスワードを入力する形にしてましたが、それはそれで面倒なので、ボタン一発で入れる仕組みに変えました。

やりたかったこと

  • デモ版:「デモを体験する」ボタン一発でログイン → アプリへ
  • 本運用版:通常のログイン画面
  • コードは共通、Vercelの環境変数で切り替え

Vercelにデモ用と本運用用の2つのプロジェクトを用意して、環境変数だけ変えることでUIを出し分けます。

# デモVercelプロジェクトのみ設定
NEXT_PUBLIC_DEMO_MODE=true
[email protected]   # NEXT_PUBLIC_禁止(セキュリティのため)
DEMO_PASSWORD=xxxxxxxx        # NEXT_PUBLIC_禁止(セキュリティのため)

DEMO_EMAILDEMO_PASSWORDNEXT_PUBLIC_をつけてはいけません。NEXT_PUBLIC_をつけるとブラウザ側のJavaScriptに値が露出してしまいます。サーバー側だけで使う値は必ずプレフィックスなしにしましょう。

最初の実装(うまく動かなかった)

まず一番シンプルな方法として、/demo/page.tsxをServer Componentで作りました。

// src/app/demo/page.tsx(最初の実装・動かなかった版)
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";

export default async function DemoLoginPage() {
  const supabase = await createClient();

  // デモアカウントでログイン
  const { error } = await supabase.auth.signInWithPassword({
    email: process.env.DEMO_EMAIL!,
    password: process.env.DEMO_PASSWORD!,
  });

  if (error) {
    // エラー処理
  }

  redirect("/fields"); // ログイン後にアプリへ
}

一見シンプルで正しそうに見えます。でも実際に動かすとログイン画面に戻ってしまいます。

なぜ動かなかったのか

結論からいうと原因は、Server Componentではログイン後のCookieを書き込めなかったことです。

原因を理解するには、以前の記事で説明したcreateClient()の仕組みを知る必要があります。
ここでいう createClient() は、Supabaseに接続するための準備をする関数です。
どの場所で使うかによって、Cookieを書き込めるかどうかが変わります。

ユーザー名ログインを実装したときの話(前回記事)

前回の実装でsrc/utils/supabase/server.tsset()remove()を空にしました。

// src/utils/supabase/server.ts
export async function createClient() {
  return createServerClient(..., {
    cookies: {
      get(name) {
        return cookieStore.get(name)?.value;
      },
      set() {
        // 空!何もしない
      },
      remove() {
        // 空!何もしない
      },
    },
  });
}

空にした理由は前回記事で説明しましたが、改めて整理します。

Cookieを「メモ書き」で例えると

ログイン状態はブラウザのCookieという場所に「ログイン中ですよ」というメモとして保存されます。

Next.jsには2種類の処理場所があります。

  • Server Component(page.tsxなど):Cookieの読み取りはOK、書き込みはNG(Next.jsの仕様)
  • Route Handler / Server Action:Cookieの読み書きどちらもOK

set()を空にしたのは「Server Componentでエラーが出ないようにするため」でした。

でも今回の失敗はこういう流れで起きていました。

/demo にアクセス(Server Component)
↓
createClient()(set()が空の版)でログイン処理
↓
Supabaseが「ログイン成功!Cookieに保存しとくね」
↓
set()が空なので何もしない → Cookieに何も保存されない
↓
redirect("/fields")
↓
「Cookieないですよね?ログインしてください」→ ログイン画面に戻される

ログイン自体は成功しているのに、その証拠(Cookie)が保存されないので、次のページに移動した瞬間に「未ログイン」と判断されてしまいます。

Next.js + Supabaseでデモログインを作る方法

ログイン処理をRoute Handler(/api/demo-login)に切り出して、Cookieに書き込めるcreateActionClient()を使います。

ファイル構成

src/app/
├── demo/
│   ├── page.tsx          ← Server Component(ログイン済みチェックのみ)
│   └── DemoLoginClient.tsx  ← Client Component(APIを呼ぶ)
├── api/
│   └── demo-login/
│       └── route.ts      ← Route Handler(実際のログイン処理)
└── utils/supabase/
    ├── server.ts         ← Server Component用(set()空)
    └── server-action.ts  ← Route Handler / Server Action用(set()あり)

① server-action.ts(Cookieに書き込める版)

// src/utils/supabase/server-action.ts
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createActionClient() {
  const cookieStore = cookies(); // Next.js 15以降はawaitが必要な場合あり(バージョン確認を)

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value;
        },
        set(name: string, value: string, options?: CookieOptions) {
          cookieStore.set({ name, value, ...options }); // ちゃんと書き込む
        },
        remove(name: string, options?: CookieOptions) {
          cookieStore.set({ name, value: "", ...options, maxAge: 0 });
        },
      },
    }
  );
}

cookies()のawaitについて:Next.js 15以降ではcookies()が非同期になっているため、await cookies()が必要です。Next.js 14以前では不要です。使っているバージョンに合わせてください。

② route.ts(ログイン処理本体)

// src/app/api/demo-login/route.ts
import { createActionClient } from "@/utils/supabase/server-action";
import { NextResponse } from "next/server";

export async function POST() {
  const email = process.env.DEMO_EMAIL;
  const password = process.env.DEMO_PASSWORD;

  if (!email || !password) {
    return NextResponse.json({ ok: false, error: "環境変数が未設定です" }, { status: 500 });
  }

  const supabase = await createActionClient(); // 書き込める版を使う
  const { error } = await supabase.auth.signInWithPassword({ email, password });

  if (error) {
    return NextResponse.json({ ok: false, error: error.message }, { status: 401 });
  }

  return NextResponse.json({ ok: true }); // Cookieが正しく保存される
}

③ DemoLoginClient.tsx(APIを呼ぶClient Component)

// src/app/demo/DemoLoginClient.tsx
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";

export default function DemoLoginClient() {
  const router = useRouter();

  useEffect(() => {
    fetch("/api/demo-login", { method: "POST" })
      .then((res) => res.json())
      .then((data) => {
        if (data.ok) {
          router.push("/fields"); // ログイン成功したらアプリへ
        }
      });
  }, [router]);

  return (
    

デモ環境を準備中..

そのままお待ちください

); }

④ demo/page.tsx(ログイン済みチェックのみ)

// src/app/demo/page.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import DemoLoginClient from "./DemoLoginClient";

export default async function DemoLoginPage() {
  const email = process.env.DEMO_EMAIL;
  const password = process.env.DEMO_PASSWORD;

  if (!email || !password) {
    return 
環境変数 DEMO_EMAIL / DEMO_PASSWORD を設定してください。
; } // 既にログイン済みならそのままアプリへ const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (user) redirect("/fields"); // 未ログインならClient Componentでログイン処理 return ; }

成功した流れ

/demo にアクセス
↓
Server Component:ログイン済みチェック(読み取りのみ)
↓
未ログインなら DemoLoginClient を表示
↓
Client Component が /api/demo-login を呼ぶ
↓
Route Handler:createActionClient()でログイン処理
↓
set()がCookieにちゃんと保存する
↓
Client Componentが /fields に移動
↓
「Cookieありますね、どうぞ」→ アプリ表示OK ✅

まとめ

場所Cookieの書き込み使うclient
Server Component(page.tsx等)❌ NGcreateClient()(set()空)
Route Handler / Server Action✅ OKcreateActionClient()(set()あり)

今回のポイントは
Server Component はCookieの読み取りはできても、ログイン状態を書き込む処理には向きません。
ログイン処理は Route Handler や Server Action に分けると覚えておけばOKです。

(余談)本運用データをデモ環境に移す方法

農業作業のデータ移行はGitHub ActionsとSupabaseのバックアップ+リストアスクリプトで運用しています。詳細な手順は Supabaseのバックアップ・リストア実装の記事に書いていますが、ここでは流れだけ紹介します。

Step 1:バックアップをダウンロード

  1. GitHub → Actions → 「Supabase DB weekly backup」を開く
  2. 最新の成功した実行をクリック
  3. 下部の「Artifacts」→「supabase-db-backup」をダウンロード
  4. ZIPを解凍して .dump ファイルを backups/ フォルダに移動

Step 2:デモDBにリストア

# デモDBに復元
bash scripts/restore.sh demo backups/supabase_〇〇.dump

これで本運用のデータがそのままデモ環境に反映されます。

注意:リストアするとRLSポリシーも上書きされる

ここが一番ハマりやすいポイントです。.dumpファイルにはテーブルデータだけでなくRLS(Row Level Security)ポリシーも含まれています。そのためリストア後は、本運用のRLSポリシーがデモ環境に上書きされた状態になります。

デモ環境では書き込みを禁止したいため、リストア後にデモ用のRLSポリシーを再適用する必要があります。具体的には:

  • 本運用のINSERT / UPDATE / DELETEポリシーをすべてDROP
  • SELECTのみ許可するポリシーに差し替え

この再適用を忘れると、デモユーザーがデータを書き換えられる状態になってしまうので要注意です。自分はリストアスクリプトの後処理としてSQLを自動実行する形にして、うっかり忘れを防いでいます。

目次