Clerk Next.js

認証ライブラリJavaScriptNext.jsJWTOAuthセッション管理React

認証ライブラリ

Clerk Next.js

概要

Clerk Next.js は、Next.jsアプリケーション専用に設計されたモダンな認証・ユーザー管理ライブラリです。2025年現在、App Router と Pages Router の両方をフルサポートし、開発者にとって最も使いやすい認証ソリューションとして広く採用されています。JWT、OAuth 2.0、セッション管理、多要素認証(MFA)などの包括的な認証機能を提供し、数行のコードで完全なユーザー認証システムを実装できます。Reactのフックパターンと完全に統合されており、useAuthuseUseruseClerk などの直感的なAPIを提供します。

詳細

Clerk Next.js は、現代的なWebアプリケーションに求められる認証機能を網羅的に提供するライブラリです。主な特徴:

  • Next.js最適化: App Router、Pages Router、API Routes、ミドルウェアとの完全統合
  • JWT統合: 自動的なJWTトークン管理とセッションクレーム処理
  • OAuth プロバイダー: Google、GitHub、Discord、Apple、Microsoft など20+の外部認証対応
  • セッション管理: サーバーサイドとクライアントサイドでの統一的なセッション処理
  • リアクティブフック: useAuthuseUseruseClerk による宣言的な認証状態管理
  • 保護ルート: ミドルウェアベースの自動ルート保護とリダイレクト機能
  • カスタマイズ可能: 認証フロー、UI コンポーネント、リダイレクト先の完全カスタマイズ

メリット・デメリット

メリット

  • Next.js に特化した最適化により、シームレスな統合と高パフォーマンス
  • 数行のコードで完全な認証システムを実装、開発速度の大幅向上
  • JWT、OAuth、MFA、RBAC などエンタープライズレベルのセキュリティ機能
  • React hooks パターンによる直感的で宣言的なAPI設計
  • App Router と Pages Router の両方で一貫した開発体験
  • 豊富なドキュメントとアクティブなコミュニティサポート

デメリット

  • Next.js 以外のフレームワークでは使用不可(React、Vue、Angular等への移植不可)
  • 外部サービス依存のため、Clerk のサービス停止時は影響を受ける
  • 高度なカスタマイズには Clerk のエコシステム内での制約
  • 大規模アプリケーションでは利用料金が高額になる可能性
  • 自社認証システムからの移行時にベンダーロックインのリスク

参考ページ

書き方の例

基本セットアップとインストール

# Clerk Next.js パッケージのインストール
npm install @clerk/nextjs

# 環境変数の設定 (.env.local)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

# オプション: リダイレクトURL設定
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=/dashboard

App Router での基本認証設定

// app/layout.tsx - ClerkProviderでアプリ全体をラップ
import { ClerkProvider } from '@clerk/nextjs'
import './globals.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <ClerkProvider>
      <html lang="ja">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  )
}

// middleware.ts - ルート保護設定
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

const isProtectedRoute = createRouteMatcher([
  '/dashboard(.*)',
  '/admin(.*)',
  '/profile(.*)'
])

const isPublicRoute = createRouteMatcher([
  '/',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/api/public(.*)'
])

export default clerkMiddleware((auth, request) => {
  // パブリックルートは認証をスキップ
  if (isPublicRoute(request)) {
    return NextResponse.next()
  }

  // 保護されたルートでは認証を必須とする
  if (isProtectedRoute(request)) {
    auth().protect()
  }

  return NextResponse.next()
})

export const config = {
  matcher: ['/((?!.*\..*|_next).*)', '/', '/(api|trpc)(.*)']
}

サインイン・サインアップページの実装

// 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: {
            formButtonPrimary: 'bg-blue-600 hover:bg-blue-700 text-white',
            card: 'shadow-lg'
          }
        }}
        routing="hash"
        signUpUrl="/sign-up"
        redirectUrl="/dashboard"
      />
    </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: {
            formButtonPrimary: 'bg-green-600 hover:bg-green-700 text-white',
            card: 'shadow-lg'
          }
        }}
        routing="hash"
        signInUrl="/sign-in"
        redirectUrl="/dashboard"
      />
    </div>
  )
}

認証状態に基づく条件レンダリング

// app/page.tsx - ホームページでの認証状態表示
import { 
  SignedIn, 
  SignedOut, 
  SignInButton, 
  UserButton,
  useUser 
} from '@clerk/nextjs'
import Link from 'next/link'

export default function HomePage() {
  return (
    <div className="container mx-auto px-4 py-8">
      <nav className="flex justify-between items-center mb-8">
        <h1 className="text-2xl font-bold">My App</h1>
        
        <div className="flex items-center space-x-4">
          <SignedOut>
            <SignInButton mode="modal">
              <button className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
                サインイン
              </button>
            </SignInButton>
            <Link href="/sign-up">
              <button className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
                サインアップ
              </button>
            </Link>
          </SignedOut>
          
          <SignedIn>
            <Link href="/dashboard">
              <button className="text-blue-600 hover:text-blue-800">
                ダッシュボード
              </button>
            </Link>
            <UserButton 
              afterSignOutUrl="/"
              appearance={{
                elements: {
                  avatarBox: 'w-10 h-10'
                }
              }}
            />
          </SignedIn>
        </div>
      </nav>

      <main>
        <SignedOut>
          <div className="text-center">
            <h2 className="text-4xl font-bold mb-4">Welcome to My App</h2>
            <p className="text-xl text-gray-600 mb-8">
              サインインして始めましょう
            </p>
          </div>
        </SignedOut>
        
        <SignedIn>
          <WelcomeMessage />
        </SignedIn>
      </main>
    </div>
  )
}

function WelcomeMessage() {
  const { user } = useUser()
  
  return (
    <div className="text-center">
      <h2 className="text-4xl font-bold mb-4">
        こんにちは、{user?.firstName || user?.username}さん!
      </h2>
      <p className="text-xl text-gray-600">
        認証が完了しました。アプリをお楽しみください。
      </p>
    </div>
  )
}

保護されたダッシュボードページ

// app/dashboard/page.tsx - 認証必須ページ
import { auth, currentUser } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
import UserProfile from '@/components/UserProfile'
import DashboardStats from '@/components/DashboardStats'

export default async function DashboardPage() {
  // サーバーサイドでの認証チェック
  const { userId } = await auth()
  
  if (!userId) {
    redirect('/sign-in')
  }

  // 現在のユーザー情報を取得
  const user = await currentUser()
  
  if (!user) {
    redirect('/sign-in')
  }

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="mb-8">
        <h1 className="text-3xl font-bold mb-2">ダッシュボード</h1>
        <p className="text-gray-600">
          ようこそ、{user.firstName || user.username}さん
        </p>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        <UserProfile user={user} />
        <DashboardStats userId={userId} />
        
        <div className="bg-white p-6 rounded-lg shadow">
          <h3 className="text-lg font-semibold mb-4">アカウント情報</h3>
          <div className="space-y-2">
            <p><strong>メール:</strong> {user.primaryEmailAddress?.emailAddress}</p>
            <p><strong>サインアップ日:</strong> {user.createdAt?.toLocaleDateString('ja-JP')}</p>
            <p><strong>最終ログイン:</strong> {user.lastSignInAt?.toLocaleDateString('ja-JP')}</p>
          </div>
        </div>
      </div>
    </div>
  )
}

API Routes での認証とJWT処理

// app/api/profile/route.ts - 保護されたAPI
import { auth, currentUser } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export async function GET() {
  try {
    // 認証状態とJWTクレームを取得
    const { userId, sessionClaims, getToken } = await auth()
    
    if (!userId) {
      return new NextResponse(
        JSON.stringify({ error: '認証が必要です' }), 
        { status: 401 }
      )
    }

    // JWTトークンを取得(外部API呼び出し用)
    const token = await getToken()
    
    // 現在のユーザー情報を取得
    const user = await currentUser()
    
    // セッションクレームの詳細情報
    const userMetadata = {
      userId: userId,
      sessionId: sessionClaims?.sid,
      email: user?.primaryEmailAddress?.emailAddress,
      roles: sessionClaims?.metadata?.roles || [],
      permissions: sessionClaims?.metadata?.permissions || []
    }

    return NextResponse.json({
      success: true,
      user: userMetadata,
      tokenAvailable: !!token
    })
    
  } catch (error) {
    console.error('API error:', error)
    return new NextResponse(
      JSON.stringify({ error: 'サーバーエラー' }), 
      { status: 500 }
    )
  }
}

export async function PUT(request: Request) {
  try {
    const { userId } = await auth()
    
    if (!userId) {
      return new NextResponse(
        JSON.stringify({ error: '認証が必要です' }), 
        { status: 401 }
      )
    }

    const updateData = await request.json()
    
    // プロファイル更新ロジック
    // userService.updateProfile(userId, updateData)
    
    return NextResponse.json({
      success: true,
      message: 'プロファイルが更新されました'
    })
    
  } catch (error) {
    return new NextResponse(
      JSON.stringify({ error: 'プロファイル更新に失敗しました' }), 
      { status: 500 }
    )
  }
}

外部API認証とトークン管理

// components/ExternalDataFetcher.tsx - 外部APIとの連携
'use client'

import { useAuth } from '@clerk/nextjs'
import { useState, useEffect } from 'react'

interface ExternalApiData {
  id: string
  name: string
  data: any
}

export default function ExternalDataFetcher() {
  const { getToken, isLoaded, isSignedIn } = useAuth()
  const [data, setData] = useState<ExternalApiData[]>([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const fetchExternalData = async () => {
    try {
      setLoading(true)
      setError(null)
      
      // ClerkからJWTトークンを取得
      const token = await getToken()
      
      if (!token) {
        throw new Error('認証トークンが取得できません')
      }

      // 外部APIにJWTトークンを使ってリクエスト
      const response = await fetch('/api/external-data', {
        method: 'GET',
        headers: {
          'Authorization': `Bearer ${token}`,
          'Content-Type': 'application/json'
        }
      })

      if (!response.ok) {
        throw new Error(`API Error: ${response.status}`)
      }

      const result = await response.json()
      setData(result.data)
      
    } catch (err) {
      setError(err instanceof Error ? err.message : '不明なエラー')
      console.error('External API fetch error:', err)
    } finally {
      setLoading(false)
    }
  }

  // マウント時に自動取得
  useEffect(() => {
    if (isLoaded && isSignedIn) {
      fetchExternalData()
    }
  }, [isLoaded, isSignedIn])

  if (!isLoaded) {
    return <div className="animate-pulse">読み込み中...</div>
  }

  if (!isSignedIn) {
    return <div className="text-red-600">認証が必要です</div>
  }

  return (
    <div className="space-y-4">
      <div className="flex justify-between items-center">
        <h3 className="text-lg font-semibold">外部データ</h3>
        <button
          onClick={fetchExternalData}
          disabled={loading}
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {loading ? '更新中...' : '更新'}
        </button>
      </div>

      {error && (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
          エラー: {error}
        </div>
      )}

      <div className="grid gap-4">
        {data.map((item) => (
          <div key={item.id} className="bg-white p-4 rounded-lg shadow">
            <h4 className="font-medium">{item.name}</h4>
            <pre className="mt-2 text-sm text-gray-600">
              {JSON.stringify(item.data, null, 2)}
            </pre>
          </div>
        ))}
      </div>
      
      {data.length === 0 && !loading && !error && (
        <div className="text-center text-gray-500 py-8">
          データがありません
        </div>
      )}
    </div>
  )
}

高度な認証フックとカスタマイズ

// hooks/useAuthStatus.ts - カスタム認証フック
import { useAuth, useUser, useClerk } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'

export function useAuthStatus() {
  const { 
    isLoaded: authLoaded, 
    isSignedIn, 
    userId, 
    sessionId,
    getToken 
  } = useAuth()
  
  const { 
    isLoaded: userLoaded, 
    user 
  } = useUser()
  
  const { signOut, openSignIn } = useClerk()
  const router = useRouter()

  // カスタムサインアウト処理
  const handleSignOut = useCallback(async () => {
    try {
      await signOut()
      router.push('/')
    } catch (error) {
      console.error('Sign out error:', error)
    }
  }, [signOut, router])

  // トークン取得とリフレッシュ
  const getAuthToken = useCallback(async () => {
    try {
      return await getToken()
    } catch (error) {
      console.error('Token fetch error:', error)
      return null
    }
  }, [getToken])

  // 認証必須アクション
  const requireAuth = useCallback((action: () => void) => {
    if (!isSignedIn) {
      openSignIn()
      return
    }
    action()
  }, [isSignedIn, openSignIn])

  return {
    // 基本状態
    isLoaded: authLoaded && userLoaded,
    isSignedIn,
    user,
    userId,
    sessionId,
    
    // 拡張機能
    getAuthToken,
    handleSignOut,
    requireAuth,
    
    // ユーザー情報
    userEmail: user?.primaryEmailAddress?.emailAddress,
    userName: user?.firstName || user?.username || 'Unknown',
    userRoles: user?.publicMetadata?.roles as string[] || [],
    
    // 状態チェック
    isAdmin: (user?.publicMetadata?.roles as string[])?.includes('admin') || false,
    isVerified: user?.primaryEmailAddress?.verification?.status === 'verified'
  }
}

// components/AuthGuard.tsx - 認証ガードコンポーネント
import { ReactNode } from 'react'
import { useAuthStatus } from '@/hooks/useAuthStatus'
import { SignInButton } from '@clerk/nextjs'

interface AuthGuardProps {
  children: ReactNode
  fallback?: ReactNode
  requireAdmin?: boolean
  requireVerified?: boolean
}

export default function AuthGuard({ 
  children, 
  fallback, 
  requireAdmin = false,
  requireVerified = false 
}: AuthGuardProps) {
  const { 
    isLoaded, 
    isSignedIn, 
    isAdmin, 
    isVerified 
  } = useAuthStatus()

  // ローディング中
  if (!isLoaded) {
    return (
      <div className="flex justify-center items-center min-h-screen">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
      </div>
    )
  }

  // 未認証
  if (!isSignedIn) {
    return fallback || (
      <div className="flex flex-col items-center justify-center min-h-screen space-y-4">
        <h2 className="text-2xl font-bold">認証が必要です</h2>
        <p className="text-gray-600">このページにアクセスするにはサインインしてください</p>
        <SignInButton mode="modal">
          <button className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700">
            サインイン
          </button>
        </SignInButton>
      </div>
    )
  }

  // 管理者権限チェック
  if (requireAdmin && !isAdmin) {
    return (
      <div className="flex flex-col items-center justify-center min-h-screen space-y-4">
        <h2 className="text-2xl font-bold text-red-600">アクセス拒否</h2>
        <p className="text-gray-600">このページにアクセスする権限がありません</p>
      </div>
    )
  }

  // メール認証チェック
  if (requireVerified && !isVerified) {
    return (
      <div className="flex flex-col items-center justify-center min-h-screen space-y-4">
        <h2 className="text-2xl font-bold text-yellow-600">メール認証が必要</h2>
        <p className="text-gray-600">このページにアクセスするにはメールアドレスの認証が必要です</p>
      </div>
    )
  }

  return <>{children}</>
}