1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

また、記事の内容は公式ドキュメントに沿っていますので、こちらも適宜ご参照ください。

本記事の構成

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

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

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

1-0. そもそも Supabase とは?

Supabase は、オープンソースの Firebase 代替サービスとして注目されている BaaS(Backend as a Service)です。

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

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

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

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

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

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

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

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

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

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

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

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

実行する SQL(公式チュートリアル準拠)

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

-- プロフィール情報を格納するテーブル
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 を使うには、プロジェクトの URLAPI キー(anon key) を取得する必要があります。

API キーの確認手順

  1. Supabase のダッシュボード にアクセスします。

  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 アプリを作成します。

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. アプリの起動確認

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

npm run dev

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

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

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

npm install @supabase/supabase-js

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

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

NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

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

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

4. 認証機能の実装

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

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

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

npm install @supabase/ssr

4.2 Supabase クライアントの構成

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

クライアント用(ブラウザで使用)

// 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 ルートやサーバーコンポーネントで使用)

// 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 によるセッション管理

// 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)$).*)",
  ],
};
// 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 認証ルートとリダイレクト

// 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);
}
// 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 レイアウトとヘッダー

// 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>
  );
}
// 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>
  );
}
// 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 サインアウトエンドポイント

// 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 の実装例です。

// 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>
  );
}
// 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 ディレクトリ構成(参考)

/
├── 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. アプリの起動

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

npm run dev

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

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?