この記事の要点
• Clerkでソーシャルログイン・パスワードレス認証を即座に実装
• Next.js App Routerと統合し、サーバー・クライアント双方で認証状態を管理
• MiddlewareとRBACで認可を制御し、安全なルーティングを実現
Clerkとは
Clerkは、フルマネージド認証・認可プラットフォームです。ソーシャルログイン、多要素認証、ユーザープロフィール管理、組織管理をUIコンポーネントとして提供します。
Clerkの特徴
| 機能 | 説明 |
|---|---|
| ソーシャルログイン | Google、GitHub、Discord、Slackなど20以上 |
| パスワードレス | Email/SMSマジックリンク、ワンタイムコード |
| MFA | TOTP、SMS、バックアップコード |
| プリビルドUI | サインイン・サインアップコンポーネント |
| セッション管理 | JWT、マルチセッション対応 |
| 組織管理 | B2Bアプリ向けチーム・ロール管理 |
ポイント: Clerkはホステッド認証ページと埋め込みコンポーネントの両方をサポートします。
プロジェクト作成
# Clerkダッシュボード(https://dashboard.clerk.com/)でアプリを作成
# APIキーを取得:
# - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
# - CLERK_SECRET_KEY
Next.js App Routerプロジェクト
# Next.jsプロジェクト作成
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
# Clerkパッケージのインストール
npm install @clerk/nextjs
環境変数
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxx
CLERK_SECRET_KEY=sk_test_xxxxx
# リダイレクト先(オプション)
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
実践メモ: NEXT_PUBLIC_プレフィックスはクライアント側でも使える環境変数です。
Middleware設定
// middleware.ts
import { authMiddleware } from '@clerk/nextjs';
export default authMiddleware({
// 公開ルート(認証不要)
publicRoutes: ['/', '/api/webhook'],
// 無視するルート(静的ファイル等)
ignoredRoutes: ['/api/public'],
});
export const config = {
matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
};
注意: matcherは静的ファイルを除外するよう設定してください。すべてのリクエストに適用するとパフォーマンスが低下します。
ClerkProviderの設定
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
import { dark } from '@clerk/themes';
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider
appearance={{
baseTheme: dark,
variables: {
colorPrimary: '#6366f1',
colorBackground: '#0f172a',
colorText: '#f1f5f9',
},
elements: {
formButtonPrimary: 'bg-indigo-500 hover:bg-indigo-600',
card: 'bg-slate-800',
},
}}
>
<html lang="ja">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
サインイン・サインアップページ
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignIn
appearance={{
elements: {
rootBox: 'mx-auto',
card: 'shadow-xl',
},
}}
/>
</div>
);
}
// app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from '@clerk/nextjs';
export default function SignUpPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignUp
appearance={{
elements: {
rootBox: 'mx-auto',
card: 'shadow-xl',
},
}}
/>
</div>
);
}
ユーザー情報の取得
サーバーコンポーネント
// app/dashboard/page.tsx
import { currentUser } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const user = await currentUser();
if (!user) {
redirect('/sign-in');
}
return (
<div>
<h1>Welcome, {user.firstName}!</h1>
<p>Email: {user.emailAddresses[0].emailAddress}</p>
<p>User ID: {user.id}</p>
{user.publicMetadata.role && (
<p>Role: {user.publicMetadata.role}</p>
)}
</div>
);
}
クライアントコンポーネント
// app/components/UserProfile.tsx
'use client';
import { useUser } from '@clerk/nextjs';
export default function UserProfile() {
const { isLoaded, isSignedIn, user } = useUser();
if (!isLoaded) {
return <div>Loading...</div>;
}
if (!isSignedIn) {
return <div>Please sign in</div>;
}
return (
<div>
<h2>{user.fullName}</h2>
<p>{user.primaryEmailAddress?.emailAddress}</p>
<img src={user.imageUrl} alt="Avatar" className="w-12 h-12 rounded-full" />
</div>
);
}
ポイント: currentUser()はサーバー側、useUser()はクライアント側で使います。
認可(Authorization)
Server Actions
// app/actions/posts.ts
'use server';
import { auth } from '@clerk/nextjs/server';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const { userId } = auth();
if (!userId) {
throw new Error('Unauthorized');
}
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.post.create({
data: {
title,
content,
authorId: userId,
},
});
return post;
}
export async function deletePost(postId: string) {
const { userId } = auth();
const post = await db.post.findUnique({
where: { id: postId },
});
if (post?.authorId !== userId) {
throw new Error('Forbidden');
}
await db.post.delete({
where: { id: postId },
});
}
API Routes
// app/api/users/route.ts
import { auth, currentUser } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
export async function GET() {
const { userId } = auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await currentUser();
return NextResponse.json({
id: user?.id,
email: user?.emailAddresses[0].emailAddress,
role: user?.publicMetadata.role,
});
}
export async function POST(request: Request) {
const { userId } = auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// ビジネスロジック
return NextResponse.json({ success: true });
}
実践メモ: auth()は軽量でユーザーIDのみ取得、currentUser()は完全なユーザーオブジェクトを取得します。
メタデータ管理
Public Metadata(公開)
// Clerkダッシュボードまたはバックエンドから設定
import { clerkClient } from '@clerk/nextjs/server';
export async function updateUserRole(userId: string, role: string) {
await clerkClient.users.updateUserMetadata(userId, {
publicMetadata: {
role,
department: 'Engineering',
},
});
}
// フロントエンドで取得
const { user } = useUser();
const role = user?.publicMetadata.role;
Private Metadata(非公開)
// バックエンドのみでアクセス可能
await clerkClient.users.updateUserMetadata(userId, {
privateMetadata: {
stripeCustomerId: 'cus_xxxxx',
internalNotes: 'VIP customer',
},
});
注意: publicMetadataはクライアントから読み取り可能です。機密情報はprivateMetadataに保存してください。
組織管理(Organization)
// app/components/OrganizationSwitcher.tsx
'use client';
import { OrganizationSwitcher } from '@clerk/nextjs';
export default function OrgSwitcher() {
return (
<OrganizationSwitcher
appearance={{
elements: {
rootBox: 'flex items-center',
},
}}
hidePersonal={false}
afterCreateOrganizationUrl="/org/:slug"
afterSelectOrganizationUrl="/org/:slug"
/>
);
}
// app/org/[slug]/page.tsx
import { auth } from '@clerk/nextjs/server';
import { clerkClient } from '@clerk/nextjs/server';
export default async function OrganizationPage({ params }: { params: { slug: string } }) {
const { orgId, userId } = auth();
if (!orgId) {
return <div>No organization selected</div>;
}
const organization = await clerkClient.organizations.getOrganization({
organizationId: orgId,
});
const memberships = await clerkClient.organizations.getOrganizationMembershipList({
organizationId: orgId,
});
return (
<div>
<h1>{organization.name}</h1>
<h2>Members ({memberships.length})</h2>
<ul>
{memberships.map((member) => (
<li key={member.id}>
{member.publicUserData.firstName} - {member.role}
</li>
))}
</ul>
</div>
);
}
Webhooks設定
// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { WebhookEvent } from '@clerk/nextjs/server';
import { db } from '@/lib/db';
export async function POST(req: Request) {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
throw new Error('Please add CLERK_WEBHOOK_SECRET to .env');
}
// ヘッダーの取得
const headerPayload = headers();
const svix_id = headerPayload.get('svix-id');
const svix_timestamp = headerPayload.get('svix-timestamp');
const svix_signature = headerPayload.get('svix-signature');
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Error: Missing headers', { status: 400 });
}
const payload = await req.json();
const body = JSON.stringify(payload);
// Webhookの検証
const wh = new Webhook(WEBHOOK_SECRET);
let evt: WebhookEvent;
try {
evt = wh.verify(body, {
'svix-id': svix_id,
'svix-timestamp': svix_timestamp,
'svix-signature': svix_signature,
}) as WebhookEvent;
} catch (err) {
console.error('Error verifying webhook:', err);
return new Response('Error: Verification failed', { status: 400 });
}
// イベント処理
const eventType = evt.type;
if (eventType === 'user.created') {
const { id, email_addresses, first_name, last_name } = evt.data;
await db.user.create({
data: {
clerkId: id,
email: email_addresses[0].email_address,
firstName: first_name,
lastName: last_name,
},
});
}
if (eventType === 'user.updated') {
const { id, email_addresses, first_name, last_name } = evt.data;
await db.user.update({
where: { clerkId: id },
data: {
email: email_addresses[0].email_address,
firstName: first_name,
lastName: last_name,
},
});
}
if (eventType === 'user.deleted') {
const { id } = evt.data;
await db.user.delete({
where: { clerkId: id },
});
}
return new Response('', { status: 200 });
}
ポイント: Webhooksを使うと、ユーザー作成・更新・削除を自動的にデータベースに同期できます。
カスタムセッションクレーム
// Clerkダッシュボード > Sessions > Customize session token
// JSON Template例
{
"metadata": "{{user.public_metadata}}",
"role": "{{user.public_metadata.role}}",
"orgId": "{{org.id}}",
"orgSlug": "{{org.slug}}"
}
// app/components/RoleGate.tsx
'use client';
import { useAuth } from '@clerk/nextjs';
export default function RoleGate({ children, allowedRoles }: { children: React.ReactNode; allowedRoles: string[] }) {
const { sessionClaims } = useAuth();
const userRole = sessionClaims?.metadata?.role as string;
if (!allowedRoles.includes(userRole)) {
return <div>Access denied</div>;
}
return <>{children}</>;
}
マルチテナント対応
// middleware.ts
import { authMiddleware } from '@clerk/nextjs';
export default authMiddleware({
publicRoutes: ['/', '/api/webhook'],
// 組織IDを検証
afterAuth(auth, req) {
const { orgId } = auth;
const url = req.nextUrl;
// 組織コンテキストが必要なルート
if (url.pathname.startsWith('/org') && !orgId) {
return Response.redirect(new URL('/select-org', req.url));
}
},
});
実践メモ: afterAuthフックで組織選択を強制し、B2B SaaSのマルチテナント分離を実現できます。
UIコンポーネント
// app/components/Header.tsx
'use client';
import { SignedIn, SignedOut, SignInButton, UserButton } from '@clerk/nextjs';
export default function Header() {
return (
<header className="flex justify-between items-center p-4">
<h1>My App</h1>
<div>
<SignedOut>
<SignInButton mode="modal">
<button className="px-4 py-2 bg-indigo-500 text-white rounded">
Sign In
</button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton
appearance={{
elements: {
avatarBox: 'w-10 h-10',
},
}}
afterSignOutUrl="/"
/>
</SignedIn>
</div>
</header>
);
}
セッション管理
// app/api/sessions/route.ts
import { auth } from '@clerk/nextjs/server';
import { clerkClient } from '@clerk/nextjs/server';
export async function GET() {
const { userId, sessionId } = auth();
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// すべてのセッションを取得
const sessions = await clerkClient.sessions.getSessionList({
userId,
});
return Response.json({
currentSessionId: sessionId,
sessions: sessions.map((s) => ({
id: s.id,
lastActiveAt: s.lastActiveAt,
expireAt: s.expireAt,
})),
});
}
export async function DELETE(request: Request) {
const { sessionId: targetSessionId } = await request.json();
const { userId } = auth();
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// セッションを無効化
await clerkClient.sessions.revokeSession(targetSessionId);
return Response.json({ success: true });
}
注意: revokeSessionは即座にセッションを無効化します。ユーザーは再ログインが必要になります。