Clerk実践 - 認証基盤を最短で構築する

初級 | 15分 で読める | 2026.04.24

公式ドキュメント

Clerkとは

Clerkは、フルマネージド認証・認可プラットフォームです。ソーシャルログイン、多要素認証、ユーザープロフィール管理、組織管理をUIコンポーネントとして提供します。

Clerkの特徴

機能説明
ソーシャルログインGoogle、GitHub、Discord、Slackなど20以上
パスワードレスEmail/SMSマジックリンク、ワンタイムコード
MFATOTP、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=YOUR_CLERK_SECRET_KEY

# リダイレクト先(オプション)
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は即座にセッションを無効化します。ユーザーは再ログインが必要になります

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リソース

関連記事

← 一覧に戻る
PR
PR
PR
PR