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

初級 | 15分 で読める | 2026.04.24

公式ドキュメント

この記事の要点

Clerkでソーシャルログイン・パスワードレス認証を即座に実装
• Next.js App Routerと統合し、サーバー・クライアント双方で認証状態を管理
MiddlewareとRBACで認可を制御し、安全なルーティングを実現

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=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は即座にセッションを無効化します。ユーザーは再ログインが必要になります。

関連記事

この技術を体系的に学びたいですか?

未来学では東証プライム上場企業のITエンジニアが24時間サポート。月額24,800円から、退会金0円のオンラインIT塾です。

メールで無料相談する
← 一覧に戻る