本記事では、Supabase と Next.js(TypeScript) を組み合わせて、認証機能とプロフィール管理機能を備えたユーザー管理アプリの構築方法を紹介します。
ログイン機能を必要とする様々な Web アプリにおいて、認証基盤のひな型として応用できる内容になっていますので、ぜひ参考にしていただければ幸いです。
また、記事の内容は公式ドキュメントに沿っていますので、こちらも適宜ご参照ください。
本記事の構成
- Supabase プロジェクトの作成
- API キーの取得
- Next.js アプリの構築
- 認証機能の実装
- アプリの起動
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 を使うには、プロジェクトの URL と API キー(anon key) を取得する必要があります。
API キーの確認手順
-
Supabase のダッシュボード にアクセスします。
-
対象のプロジェクトを選択します。
-
左メニューから 「Project Settings」→「API」 をクリックします。
-
次の情報を確認しておきます:
-
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_URL
とYOUR_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 による認証付きアプリの構築は完了です。