Next.js × Supabase で始める認証付きユーザー管理アプリ

本記事では、Supabase と Next.js（TypeScript） を組み合わせて、認証機能とプロフィール管理機能を備えたユーザー管理アプリの構築方法を紹介します。

ログイン機能を必要とする様々な Web アプリにおいて、認証基盤のひな型として応用できる内容になっていますので、ぜひ参考にしていただければ幸いです。

また、記事の内容は[公式ドキュメント](https://supabase.com/docs/guides/getting-started/tutorials/with-nextjs?queryGroups=database-method&database-method=sql&queryGroups=language&language=ts)に沿っていますので、こちらも適宜ご参照ください。

## 本記事の構成

1. Supabase プロジェクトの作成
2. API キーの取得
3. Next.js アプリの構築
4. 認証機能の実装
5. アプリの起動

## 1. Supabase プロジェクトの作成

まずは、Supabase のダッシュボードから新しいプロジェクトを作成します。以下の手順に沿って準備を進めましょう。

### 1-0. そもそも Supabase とは？

[Supabase](https://supabase.com/) は、オープンソースの Firebase 代替サービスとして注目されている BaaS（Backend as a Service）です。

以下のような機能を持ち、サーバーレスで本格的なバックエンドを構築できます。

- PostgreSQL ベースのデータベース（SQL 対応）
- 認証機能（メール・OAuth・パスワードレス等に対応）
- ストレージ（画像・動画・ファイルのアップロード管理）
- リアルタイム（Postgres の変更を WebSocket で即座に反映）
- REST & GraphQL API 自動生成
- 完全無料のプランから利用可能

Next.js や React との相性がよく、最小構成でも認証付きアプリをすばやく構築できるのが特徴です。

今回はこの Supabase を使って、認証とプロフィール機能を備えたユーザー管理アプリを構築していきます。

### 1-1. Supabase にログイン / サインアップ

[https://supabase.com/](https://supabase.com/) にアクセスし、GitHub アカウントなどを使ってログインします。

### 1-2. 新しいプロジェクトを作成

ログイン後、以下のようにプロジェクトを作成します。

- 「New Project」ボタンをクリック
- プロジェクト名（例：`nextjs-user-auth-app`）を入力
- データベースのパスワードを設定（忘れないようにしましょう）
- リージョンは任意（日本を選択）

### 1-3. プロフィール管理用のテーブルと自動登録の仕組みを設定する

Supabase では、ユーザー認証とは別にプロフィール情報（名前やユーザー名など）を管理するための独自テーブルを作成する必要があります。  
このセクションでは、認証されたユーザーに対応するプロフィールを安全かつ自動で管理するためのセットアップを行います。

以下の SQL スクリプトを Supabase の「SQL Editor」（左サイドメニュー）から実行してください。

### 実行する SQL（公式チュートリアル準拠）

「SQL Editor」を開き、以下のスクリプトを貼り付けて「Run」ボタンを押します。

```sql
-- プロフィール情報を格納するテーブル
create table profiles (
  id uuid references auth.users not null primary key,
  updated_at timestamp with time zone,
  username text unique,
  full_name text,
  website text,
  constraint username_length check (char_length(username) >= 3)
);

-- RLSを有効にする
alter table profiles
  enable row level security;

-- 誰でも閲覧可能
create policy "Public profiles are viewable by everyone." on profiles
  for select using (true);

-- 自分のプロフィールのみ作成可能
create policy "Users can insert their own profile." on profiles
  for insert with check ((select auth.uid()) = id);

-- 自分のプロフィールのみ更新可能
create policy "Users can update own profile." on profiles
  for update using ((select auth.uid()) = id);

-- 新規ユーザー登録時にプロフィールを作成
create function public.handle_new_user()
returns trigger
set search_path = ''
as $$
begin
  insert into public.profiles (id, full_name)
  values (new.id, new.raw_user_meta_data->>'full_name');
  return new;
end;
$$ language plpgsql security definer;

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

```

### 各 SQL の解説

#### `create table profiles`

ユーザーごとのプロフィール情報を格納するテーブルです。

- **`id`**: Supabase Auth の `auth.users` テーブルと 1 対 1 で対応する外部キー。
- **`updated_at`**: プロフィールの更新日時。
- **`username`**: ユーザー名（ユニーク制約付き）。
- **`full_name`**: 表示用のフルネーム。
- **`website`**: 任意のウェブサイト URL。
- **`constraint username_length`**: ユーザー名は 3 文字以上でなければならないという制約。

#### `alter table profiles enable row level security;`

RLS（Row Level Security）を有効にし、ポリシーに基づいたアクセス制御を可能にします。  
これを有効にしないと、`create policy` で定義しても機能しません。

#### `create policy ...`

プロフィールへのアクセスルールを設定するポリシー群です。

- 閲覧ポリシー: 誰でも全プロフィールを閲覧可能にします。
- 挿入ポリシー: 自分自身の `id` に一致するプロフィールのみ作成可能（なりすまし防止）。
- 更新ポリシー: 自分のプロフィールのみ編集可能。他人のプロフィールは変更不可。

#### `create function handle_new_user()`

ユーザーが `auth.users` に登録された直後に発火するトリガー関数です。

- **`new.id`**: 登録されたユーザーの ID。
- **`new.raw_user_meta_data->>'full_name'`**: 登録時に渡されたカスタムメタデータから `full_name` を抽出。
- **`security definer`**: この関数は作成者の権限で実行され、RLS をバイパスできます（安全に自動登録が可能）。

#### `create trigger on_auth_user_created`

新規ユーザー登録時に `handle_new_user()` を**自動実行するトリガー**です。

- **`after insert on auth.users`**: 認証テーブルにユーザーが追加された後に発火。
- **`for each row`**: 各ユーザーに対して個別に実行。

この設定により、ユーザーが登録されると自動的に profiles テーブルにプロフィールが作成され、
その後は本人のみが編集可能、誰でも閲覧可能という安全な運用が可能になります。

次は、このプロフィール情報を Next.js アプリから取得・利用するために「2. API キーの取得」に進みましょう。

## 2. API キーの取得

Supabase では、データベースを操作するために **自動生成された API** を利用できます。
この API を使うには、**プロジェクトの URL** と **API キー（anon key）** を取得する必要があります。

### API キーの確認手順

1. [Supabase のダッシュボード](https://app.supabase.com/) にアクセスします。
2. 対象のプロジェクトを選択します。
3. 左メニューから **「Project Settings」→「API」** をクリックします。
4. 次の情報を確認しておきます：

   - **Project URL**
     → API リクエスト時のベース URL（例: `https://your-project.supabase.co`）

   - **anon key**
     → クライアントサイドで使用する公開キー（RLS 適用下で読み書き可能）

   - **service_role key**（※今回は使用しません）
     → 管理者専用の強力なキーです。**フロントエンドでは絶対に使用しないでください。**

> **今回のプロジェクトでは `anon key` のみを使用します。** > `service_role key` は、RLS（Row Level Security）を無視してすべてのデータにアクセスできるため、
> 管理者操作やサーバー側処理（バッチ処理・管理画面など）でのみ使用します。

これらのキーは、Next.js アプリから Supabase に安全に接続するために使用されます。
次のステップでは、これらを `.env.local` に設定し、Supabase クライアントを初期化します。

## 3. Next.js アプリの構築

Supabase と連携する Next.js アプリの構築を始めましょう。
このセクションでは、プロジェクトの初期化、Supabase クライアントの導入、環境変数の設定までを行います。

### 3-1. Next.js プロジェクトを初期化する

以下のコマンドで、Next.js アプリを作成します。

```bash
npx create-next-app@latest
```

対話形式でいくつかオプションが表示されるので、以下のように選択します：

- **Project name**: `supabase-nextjs`（任意の名前）
- **TypeScript**: `Yes`（TypeScript を使用）
- **ESLint**: `Yes`
- **Tailwind CSS**: `Yes`（必要に応じて）
- **src/ ディレクトリの使用**: `No`（任意）
- **App Router を使用するか**: `Yes`
- **Custom import alias**: `No`

> TypeScript + App Router を選択することで、公式チュートリアルと同じ構成になります。

### 3-2. アプリの起動確認

開発サーバーを起動して、初期状態が正常に表示されるか確認します。

```bash
npm run dev
```

ブラウザで [http://localhost:3000](http://localhost:3000) にアクセスし、Next.js の初期ページが表示されれば準備完了です。

### 3-3. Supabase クライアントをインストールする

Supabase と通信するための公式クライアントライブラリをインストールします。

```bash
npm install @supabase/supabase-js
```

### 3-4. 環境変数を `.env.local` に保存する

API キーをソースコードに直書きせず、環境変数として安全に管理するために `.env.local` ファイルを作成します。プロジェクトのルートに以下を追加してください：

```env
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
```

> `YOUR_SUPABASE_URL` と `YOUR_SUPABASE_ANON_KEY` は、前のステップで取得した値に置き換えてください。
> `NEXT_PUBLIC_` を付けることで、クライアントサイドから参照できるようになります。

これで、Next.js アプリと Supabase を接続する準備が整いました。
次は **Supabase クライアントの初期化**を行い、アプリ内から Supabase API を安全に利用できるようにします。

## 4. 認証機能の実装

このセクションでは、Next.js と Supabase を活用して、安全な認証機能を構築する手順を詳しく説明します。

### 4.1 パッケージのインストール

まず、Supabase の認証機能と Next.js を統合するために `@supabase/ssr` パッケージをインストールします：

```bash
npm install @supabase/ssr
```

### 4.2 Supabase クライアントの構成

用途に応じて、2 種類の Supabase クライアントを作成します：

#### クライアント用（ブラウザで使用）

```ts
// 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!
  );
}
```

#### サーバー用（API ルートやサーバーコンポーネントで使用）

```ts
// 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 {}
        },
      },
    }
  );
}
```

### 4.3 Middleware によるセッション管理

```ts
// middleware.ts
import { type NextRequest } from "next/server";
import { updateSession } from "./utils/supabase/middleware";

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

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

```ts
// utils/supabase/middleware.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, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

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

  if (
    !user &&
    !request.nextUrl.pathname.startsWith("/login") &&
    !request.nextUrl.pathname.startsWith("/signup") &&
    !request.nextUrl.pathname.startsWith("/auth")
  ) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}
```

### 4.4 認証ルートとリダイレクト

```ts
// app/auth/confirm/route.ts
import { type EmailOtpType } from "@supabase/supabase-js";
import { type NextRequest, NextResponse } from "next/server";
import { createClient } from "@/utils/supabase/server";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const token_hash = searchParams.get("token_hash");
  const type = searchParams.get("type") as EmailOtpType | null;

  const redirectTo = request.nextUrl.clone();
  redirectTo.pathname = "/account";
  redirectTo.searchParams.delete("token_hash");
  redirectTo.searchParams.delete("type");

  if (token_hash && type) {
    const supabase = await createClient();
    const { error } = await supabase.auth.verifyOtp({ type, token_hash });
    if (!error) {
      return NextResponse.redirect(redirectTo);
    }
  }

  redirectTo.pathname = "/error";
  return NextResponse.redirect(redirectTo);
}
```

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

export default async function Home() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  redirect(user ? "/account" : "/login");
}
```

### 4.5 レイアウトとヘッダー

```tsx
// app/layout.tsx
import "./globals.css";
import { Geist, Geist_Mono } from "next/font/google";
import Header from "./components/Header";

const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <Header />
        {children}
      </body>
    </html>
  );
}
```

```tsx
// app/components/Header.tsx
import { createClient } from "@/utils/supabase/server";
import LogoutButton from "./LogoutButton";

export default async function Header() {
  const supabase = await createClient();
  const { data } = await supabase.auth.getUser();

  return (
    <header className="w-full px-6 py-4 bg-gray-100 flex justify-between items-center text-sm">
      <span className="font-bold text-xl">My App</span>
      <div className="flex items-center gap-2">
        {data?.user ? (
          <>
            <span>ログイン中: {data.user.email}</span>
            <LogoutButton />
          </>
        ) : (
          <p>未ログイン</p>
        )}
      </div>
    </header>
  );
}
```

```tsx
// app/components/LogoutButton.tsx
"use client";

export default function LogoutButton() {
  const handleLogout = async () => {
    const res = await fetch("/auth/signout", { method: "POST" });
    if (res.redirected) window.location.href = res.url;
    else console.error("ログアウトに失敗しました");
  };

  return (
    <button
      onClick={handleLogout}
      className="bg-red-500 text-white px-4 py-2 rounded"
    >
      ログアウト
    </button>
  );
}
```

### 4.6 サインアウトエンドポイント

```ts
// app/auth/signout/route.ts
import { createClient } from "@/utils/supabase/server";
import { revalidatePath } from "next/cache";
import { type NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (user) await supabase.auth.signOut();
  revalidatePath("/", "layout");
  return NextResponse.redirect(new URL("/login", req.url), { status: 302 });
}
```

### 4.7 アカウント管理 UI

以下はユーザーのプロフィール情報を表示・編集するアカウント管理 UI の実装例です。

```tsx
// app/account/account-form.tsx
"use client";
import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/utils/supabase/client";
import { type User } from "@supabase/supabase-js";

export default function AccountForm({ user }: { user: User | null }) {
  const supabase = createClient();
  const [loading, setLoading] = useState(true);
  const [fullname, setFullname] = useState<string | null>(null);
  const [username, setUsername] = useState<string | null>(null);
  const [website, setWebsite] = useState<string | null>(null);

  const getProfile = useCallback(async () => {
    try {
      setLoading(true);
      const { data, error, status } = await supabase
        .from("profiles")
        .eq("id", user?.id)
        .single();

      if (error && status !== 406) throw error;
      if (data) {
        setFullname(data.full_name);
        setUsername(data.username);
        setWebsite(data.website);
      }
    } catch {
      alert("Error loading user data!");
    } finally {
      setLoading(false);
    }
  }, [user, supabase]);

  useEffect(() => {
    getProfile();
  }, [user, getProfile]);

  async function updateProfile({
    username,
    website,
    fullname,
  }: {
    username: string | null;
    fullname: string | null;
    website: string | null;
  }) {
    try {
      setLoading(true);
      const { error } = await supabase.from("profiles").upsert({
        id: user?.id as string,
        full_name: fullname,
        username,
        website,
        updated_at: new Date().toISOString(),
      });
      if (error) throw error;
      alert("Profile updated!");
    } catch {
      alert("Error updating the data!");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="max-w-md w-full mx-auto bg-white p-6 rounded-lg shadow-md space-y-6">
      <div className="flex justify-center">      <div className="space-y-4">
        <div>
          <label
            htmlFor="email"
            className="block text-sm font-medium text-gray-700"
          >
            Email
          </label>
          <input
            id="email"
            type="text"
            value={user?.email}
            disabled
            className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-100 p-2 shadow-sm text-sm text-gray-600"
          />
        </div>

        <div>
          <label
            htmlFor="fullName"
            className="block text-sm font-medium text-gray-700"
          >
            Full Name
          </label>
          <input
            id="fullName"
            type="text"
            value={fullname || ""}
            onChange={(e) => setFullname(e.target.value)}
            className="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm"
          />
        </div>

        <div>
          <label
            htmlFor="username"
            className="block text-sm font-medium text-gray-700"
          >
            Username
          </label>
          <input
            id="username"
            type="text"
            value={username || ""}
            onChange={(e) => setUsername(e.target.value)}
            className="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm"
          />
        </div>

        <div>
          <label
            htmlFor="website"
            className="block text-sm font-medium text-gray-700"
          >
            Website
          </label>
          <input
            id="website"
            type="url"
            value={website || ""}
            onChange={(e) => setWebsite(e.target.value)}
            className="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm"
          />
        </div>
      </div>

      <div className="pt-4 flex flex-col gap-3">
        <button
          onClick={() =>
          }
          disabled={loading}
          className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md disabled:opacity-50"
        >
          {loading ? "Updating..." : "Update Profile"}
        </button>

        <form action="/auth/signout" method="post">
          <button
            type="submit"
            className="w-full bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold py-2 px-4 rounded-md"
          >
            Sign Out
          </button>
        </form>
      </div>
    </div>
  );
}
```

```tsx
// app/account/page.tsx
import AccountForm from "./account-form";
import { createClient } from "@/utils/supabase/server";

export default async function Account() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  return <AccountForm user={user} />;
}
```

### 4.8 ディレクトリ構成（参考）

```txt
/
├── app/
│   ├── page.tsx
│   ├── layout.tsx
│   ├── globals.css
│   ├── signup/
│   ├── account/
│   ├── auth/
│   ├── login/
│   ├── error/
│   └── components/
│       └── Header.tsx
├── public/
├── utils/
│   └── supabase/
│       ├── client.ts
│       ├── server.ts
│       └── middleware.ts
├── middleware.ts
...
```

以上が Next.js + Supabase による認証機能の全体像です。

## 5. アプリの起動

全てのページ・ルートハンドラ・コンポーネントの実装が完了したら、以下のコマンドでアプリを起動しましょう：

```bash
npm run dev
```

ブラウザで `http://localhost:3000` にアクセスすると、アプリケーションが表示されるはずです。

これで、Next.js + Supabase による認証付きアプリの構築は完了です。