本記事では、Supabase × Next.js 環境に軽量な状態管理ライブラリZustandを導入し、ログイン状態をグローバルに監視する方法を解説します。
実際にログイン中かどうかでヘッダーに表示するボタン(ログイン/ログアウト、プロフィールなど)を切り替える実装を行います。
📌 zustandとは?
Reactで使える軽量な状態管理ライブラリです。
一度取得したデータをアプリ全体で使い回せる ので、通信回数を減らし表示を高速化できます。
💡 例:ログインユーザー情報をストアに保存しておけば、ナビバーやプロフィール画面で再取得不要。
📌 ディレクトリ例
src/
├─ app/ # Next.js App
│ ├─ layout.tsx ← ⑤ で適用
│ ├─ page.tsx
│ ├─ auth/
│ │ ├─ login/
│ │ │ └─ page.tsx # ログインページ
│ │ └─ signout/
│ │ └─ page.tsx # ログアウトページ
│ └─ components/ # ページ共通やUIパーツ
│ └─ header/
│ └─ Header.tsx
├─ components/
│ └─ ClientUserSetter.tsx ← ④ で追加
├─ store/
│ └─ useUserStore.ts ← ③ で追加
├─ lib/
│ └─ database.types.ts # Supabase の型定義
└─ utils/
└─ supabase/
├─ clients.ts # ブラウザ用 Supabase
├─ server.ts # サーバー用 Supabase
└─ getCurrentUser.ts ← ② で追加
📌 導入手順
① パッケージのインストール(npm)
npm install zustand
② Supabaseからユーザー情報を取得する関数作成(getCurrentUser.ts)
supabaseのテーブルは下記とします。(テーブル名:profiles)
- id
- name
- avatar_url
💡ご自身のテーブルの内容に置き換えて使用ください。
"use server";
import type { Database } from "@/lib/database.types";
import { createClient } from "./server";
export const getCurrentUser = async () => {
const supabase = await createClient();
const {
data: { session },
} = await supabase.auth.getSession();
let profile = null;
if (session) {
const { data: currentProfile } = await supabase
.from("profiles")
.select("*")
.eq("id", session.user.id)
.single();
profile = currentProfile;
// メールアドレス変更時に同期
if (currentProfile && currentProfile.email !== session.user.email) {
const { data: updatedProfile } = await supabase
.from("profiles")
.update({ email: session.user.email })
.match({ id: session.user.id })
.select("*")
.single();
profile = updatedProfile;
}
}
return { session, profile };
};
③ Zustandストア作成(useUserStore.ts)
-
useUserStoreの中身は、ご自身の作成してテーブルの内容に変更してください -
resetUser:Zustandの状態を初期値に戻す関数
// useUserStore.ts
import { create } from "zustand";
import type { Database } from "@/lib/database.types"; // Supabaseの型がある場合
type ProfileType = Database["public"]["Tables"]["profiles"]["Row"];
type StateType = {
user: ProfileType;
setUser: (payload: ProfileType) => void;
resetUser: () => void;
};
// 中身を変更してください。
const useUserStore = create<StateType>((set) => ({
user: {
id: "",
email: "",
name: "",
avatar_url: "",
},
// 内容更新
setUser: (payload) => set({ user: payload }),
// リセット
resetUser: () =>
set({
user: {
id: "",
email: "",
name: "",
avatar_url: "",
},
}),
}));
export default useUserStore;
④ 状態をセットするClientUserSetter.tsxを作成
- サーバーで取得したログイン情報をクライアント全体で使い回すための準備になります
- ご自身の指定したテーブル名に変更してください
// components/ClientUserSetter.tsx
"use client";
import { useEffect } from "react";
import type { Session } from "@supabase/supabase-js";
import type { Database } from "@/lib/database.types";
import useUserStore from "@/store/useUserStore";
type Profile = Database["public"]["Tables"]["profiles"]["Row"];
export default function ClientUserSetter({
session,
profile,
}: {
session: Session | null;
profile: Profile | null;
}) {
const { setUser } = useUserStore();
useEffect(() => {
if (!session || !profile) return;
setUser({
id: session.user.id,
email: session.user.email ?? "",
name: profile.name,
avatar_url: profile.avatar_url,
});
}, [session, profile, setUser]);
return null;
}
役割のイメージ
- サーバー側(RSC)で
sessionとprofileを取得 -
ClientUserSetterにpropsとして渡す - クライアント側のストアを初期化
- 以降はどのページ・コンポーネントからでも
useStore().userでログイン中ユーザー情報を使える
⑤ 全体layout.tsxで適用する
-
全体を囲うlayout.tsx内で適用することで
storeに保存されたユーザー情報が簡単に取り出せます。 -
const { session, profile } = await getCurrentUser();
→ ②で作成したsupabaseからデータ取得した関数を利用
// app/layout.tsx
import ClientUserSetter from "@/components/ClientUserSetter";
import { getCurrentUser } from "@/utils/supabase/getCurrentUser"; // サーバー側で session / profile を取得
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const { session, profile } = await getCurrentUser();
return (
<html lang="ja">
<body>
<ClientUserSetter session={session} profile={profile} />
<main>{children}</main>
</body>
</html>
);
}
⑥ ログインページを作成する
💡React hook formやzodに関しては別記事にて記載しております!
-
router.refresh()を呼ぶことでApp Routerのサーバーコンポーネントが再取得され、ClientUserSetterのpropsが更新されます
// src/app/auth/login/page.tsx
"use client";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { createClient } from "@/utils/supabase/clients";
const schema = z.object({
email: z.string().email({ message: "メールアドレスの形式が不正です" }),
password: z.string().min(6, { message: "6文字以上入力してください" }),
});
type Schema = z.infer<typeof schema>;
export default function LoginPage() {
const router = useRouter();
const supabase = createClient();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Schema>({
defaultValues: { email: "", password: "" },
resolver: zodResolver(schema),
});
const onSubmit = async (data: Schema) => {
const { error } = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password,
});
if (!error) {
router.push("/"); // ログイン後トップへ
router.refresh(); // Zustandの状態更新を即時反映
} else {
console.error(error.message);
}
};
return (
<main className="max-w-sm mx-auto mt-20">
<h1 className="text-xl font-bold mb-6">ログイン</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<input
type="email"
placeholder="メールアドレス"
{...register("email")}
className="border p-2 w-full"
/>
{errors.email && (
<p className="text-red-500 text-sm">{errors.email.message}</p>
)}
</div>
<div>
<input
type="password"
placeholder="パスワード"
{...register("password")}
className="border p-2 w-full"
/>
{errors.password && (
<p className="text-red-500 text-sm">{errors.password.message}</p>
)}
</div>
<button type="submit" className="bg-blue-500 text-white px-4 py-2">
ログイン
</button>
</form>
</main>
);
}
⑦ ログアウトページを作成する
// src/app/auth/signout/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { createClient } from "@/utils/supabase/clients";
import useUserStore from "@/store/useUserStore";
export default function SignOutPage() {
const router = useRouter();
const supabase = createClient();
const { resetUser } = useUserStore();
const handleSignOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
console.error("ログアウトエラー:", error.message);
return;
}
// Zustandのユーザー状態をリセット
resetUser();
// ログアウト後トップへ
router.push("/");
router.refresh(); // 状態を即時反映
};
return (
<main className="max-w-sm mx-auto mt-20 text-center">
<h1 className="text-xl font-bold mb-6">ログアウト</h1>
<p className="mb-4">本当にログアウトしますか?</p>
<button
onClick={handleSignOut}
className="bg-red-500 text-white px-4 py-2 rounded"
>
ログアウト
</button>
</main>
);
}
⑧ ログイン状態によってヘッダーを切り替える
-
useUserStore()で簡単にユーザー情報を取得できます
// app/components/header/Header.tsx
import Link from "next/link";
import useUserStore from "@/store/useUserStore";
export default function Header() {
const { user } = useUserStore();
return (
<header className="w-full flex justify-end gap-4 p-4 bg-gray-100">
{user.id ? (
<>
<span>{user.name} さん</span>
<Link href="/auth/signout">ログアウト</Link>
</>
) : (
<>
<Link href="/auth/login">ログイン</Link>
<Link href="/auth/signup">サインアップ</Link>
</>
)}
</header>
);
}
📌 動作の流れ
[1] ユーザーがログインフォームから認証
↓
[2] Supabaseがセッション情報を発行
↓
[3] SSRの `getCurrentUser()` がセッションとプロフィールを取得
↓
[4] `ClientUserSetter` が受け取った情報をZustandに保存
↓
[5] アプリ全体から `useUserStore()` でユーザー情報を参照可能
↓
[6] Header などで状態管理が可能に。
↓
[7] ログアウト時は Supabase セッション破棄 + Zustand リセット
📌 まとめ
- Zustandは軽量・シンプルで導入が簡単
- Supabaseから取得したユーザー情報を一度だけ保存し、全コンポーネントから参照可能
- 再レンダーや無駄な通信を減らせるので、体感速度が向上
-
router.refresh()を組み合わせることで、ログイン/ログアウト後の即時反映が可能 - 再レンダーや重複通信を防ぎ、体感速度アップ にも効果的
- ヘッダーやナビゲーションなど、アプリ全体でログイン状態を共有する実装 に最適