🔐

Next.jsに Clerk認証の導入と実装してみた

に公開

本記事のサマリ

Next.js 16のアプリケーションにClerk認証を統合してみました。Google OAuthとEmail/Password認証を実装し、middlewareでのルート保護、ClerkユーザーとローカルDBの自動同期、そしてロールベースのアクセス制御まで一通り整えたので、その手順と実装のポイントをまとめます。

はじめに

Next.jsアプリに認証機能を実装する際、自前で実装するかサードパーティサービスを使うか迷いますよね。認証は実装が複雑で、セキュリティの観点からも慎重に選択したいところです。今回は開発体験の良さで話題になることの多いClerkを選び、実際に導入してみた経験をまとめました。

認証周りは一度実装すると長期間メンテナンスが必要になるので、最初の選択が重要だと考えています。

使用した技術スタック

今回使用した技術は以下の通りです。

  • 認証: Clerk (v6.35.5)
  • フレームワーク: Next.js 16 (App Router)
  • データベース: PostgreSQL + Prisma ORM
  • 認証方法: Google OAuth、Email/Password

実装した主な機能として、サインイン・サインアップ、セッション管理、middlewareによるルート保護、ClerkユーザーとローカルDBの自動同期、ロールベースアクセス制御(USER/ADMIN)があります。特にローカルDBとの同期部分は、既存のアプリケーションにClerkを導入する際に重要なポイントになると感じました。

セットアップの流れ

パッケージインストール

まずはClerk SDKをインストールします。

cd app
bun add @clerk/nextjs

環境変数の設定

Clerkダッシュボードでアプリを作成し、必要な認証キーを取得します。

https://dashboard.clerk.com/

API Keysセクションからキーを取得し、.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=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

リダイレクトURLはオプションですが、設定しておくとアプリ内での遷移がスムーズになります。

Prismaスキーマの更新

ClerkのユーザーIDを保存するため、Userモデルを追加しました。

model User {
  id        String   @id @default(cuid())
  clerkId   String   @unique  // ClerkのユーザーID
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  todos     Todo[]   @relation("CreatedTodos")
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

enum Role {
  USER
  ADMIN
}

clerkIdフィールドでClerkのユーザーIDと紐付けるのがポイントです。これでローカルDBとClerkのユーザー情報を同期できます。

マイグレーションを実行します。

bunx prisma migrate dev --name add_user_model

ClerkProviderとレイアウト設定

app/layout.tsxで全体をClerkProviderでラップします。これでアプリ全体でClerkの認証機能が使えるようになります。

import { ClerkProvider } from "@clerk/nextjs";
import Navigation from "@/components/Navigation";
import "./globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ClerkProvider>
      <html lang="ja">
        <body>
          <Navigation />
          <main className="min-h-screen bg-gradient-to-b from-background to-muted/20">
            {children}
          </main>
        </body>
      </html>
    </ClerkProvider>
  );
}

シンプルですが、これで準備が整いました。Providerのラップだけで認証機能が使えるようになるのは、Clerkの良いところの一つです。

Middlewareでルート保護

Next.jsのmiddlewareを使って、未認証ユーザーが保護されたページにアクセスできないようにします。

// app/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

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

export default clerkMiddleware(async (auth, request) => {
  if (!isPublicRoute(request)) {
    await auth.protect()
  }
})

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
}

isPublicRouteでサインイン・サインアップページを公開ルートとして定義し、それ以外は auth.protect()で保護します。未認証ユーザーは自動的にサインインページへリダイレクトされる仕組みです。

このmiddleware設定により、アプリ全体で一貫した認証チェックが行われます。

認証ページの実装

サインインページとサインアップページは、Clerkが提供するコンポーネントを配置するだけで完成します。

// app/app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs'

export default function SignInPage() {
  return (
    <div className="flex items-center justify-center min-h-[calc(100vh-4rem)] py-12">
      <SignIn />
    </div>
  )
}
// app/app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from '@clerk/nextjs'

export default function SignUpPage() {
  return (
    <div className="flex items-center justify-center min-h-[calc(100vh-4rem)] py-12">
      <SignUp />
    </div>
  )
}

これで、Google OAuthとEmail/Passwordの両方に対応したUIが自動的に表示されます。提供されるUIはシンプルで洗練されているので、カスタマイズの手間が省けるのが嬉しいポイントです。

ナビゲーションには、useAuthUserButtonSignInButtonを使います。

// app/components/Navigation.tsx
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { CheckSquare } from 'lucide-react'
import { UserButton, SignInButton, useAuth } from '@clerk/nextjs'

export default function Navigation() {
  const pathname = usePathname()
  const { isSignedIn } = useAuth()

  const links = [
    { href: '/', label: 'Todo一覧' },
    { href: '/assignees', label: '担当者一覧' },
  ]

  return (
    <nav className="border-b bg-card">
      <div className="container mx-auto px-4 max-w-6xl">
        <div className="flex items-center justify-between h-16">
          <div className="flex items-center gap-8">
            <Link href="/" className="flex items-center gap-2 font-semibold text-lg">
              <CheckSquare className="h-6 w-6 text-primary" />
              <span>Todo App</span>
            </Link>
            <div className="flex gap-1">
              {links.map((link) => (
                <Link
                  key={link.href}
                  href={link.href}
                  className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
                    pathname === link.href
                      ? 'bg-primary text-primary-foreground'
                      : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
                  }`}
                >
                  {link.label}
                </Link>
              ))}
            </div>
          </div>
          <div className="flex items-center gap-4">
            {isSignedIn ? (
              <UserButton afterSignOutUrl="/sign-in" />
            ) : (
              <SignInButton mode="modal">
                <button className="px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
                  サインイン
                </button>
              </SignInButton>
            )}
          </div>
        </div>
      </div>
    </nav>
  )
}

isSignedInでサインイン状態を判定し、サインイン済みなら UserButtonでアカウントメニューを、未サインインなら SignInButtonを表示します。UserButtonは自動的にプロフィール画像やサインアウト機能を提供してくれるので、実装が楽になります。

ユーザー情報の管理と同期

Clerkのユーザー情報とローカルDBを同期するため、lib/auth-utils.tsにヘルパー関数を作成しました。この部分は特に重要で、既存のアプリケーションにClerkを導入する際の肝となる部分です。

// app/lib/auth-utils.ts
import { auth, clerkClient } from '@clerk/nextjs/server'
import { prisma } from './prisma'

export async function getCurrentUser() {
  const { userId } = await auth()

  if (!userId) {
    return null
  }

  let user = await prisma.user.findUnique({
    where: { clerkId: userId },
  })

  if (!user) {
    const client = await clerkClient()
    const clerkUser = await client.users.getUser(userId)

    const userEmail = clerkUser.emailAddresses.find(
      (email) => email.id === clerkUser.primaryEmailAddressId
    )?.emailAddress

    if (!userEmail) {
      throw new Error('User email not found in Clerk session')
    }

    user = await prisma.user.create({
      data: {
        clerkId: userId,
        email: userEmail,
        name: clerkUser.firstName && clerkUser.lastName
          ? `${clerkUser.firstName} ${clerkUser.lastName}`.trim()
          : clerkUser.firstName || clerkUser.lastName || userEmail,
        role: 'USER',
      },
    })
  }

  return user
}

export async function requireAuth() {
  const user = await getCurrentUser()

  if (!user) {
    throw new Error('Unauthorized: Please sign in')
  }

  return user
}

export async function canEditTodo(todoId: string) {
  const user = await requireAuth()

  if (user.role === 'ADMIN') {
    return true
  }

  const todo = await prisma.todo.findUnique({
    where: { id: todoId },
    select: { createdById: true },
  })

  if (!todo) {
    return false
  }

  return todo.createdById === user.id
}

export async function isAdmin() {
  const user = await getCurrentUser()
  return user?.role === 'ADMIN'
}

getCurrentUser()は、初回サインイン時に自動的にDBにユーザーレコードを作成します。clerkClient().users.getUser(userId)でClerkからユーザー情報を取得し、プライマリメールアドレスを正しく取得するのがポイントです✨

requireAuth()canEditTodo()といった関数で、Server Actionでの認証・認可を簡単に実装できるようになります。

Server Actionでの認証・認可

Todo操作のServer Actionで、これらのヘルパー関数を使います。

// app/actions/todo.ts
'use server'

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { requireAuth, canEditTodo, isAdmin } from '@/lib/auth-utils'

export async function createTodo(formData: FormData) {
  const user = await requireAuth()

  const title = formData.get('title') as string
  const description = formData.get('description') as string
  const assigneeId = formData.get('assigneeId') as string

  await prisma.todo.create({
    data: {
      title,
      description,
      assigneeId: assigneeId || null,
      createdById: user.id,
    },
  })

  revalidatePath('/')
}

export async function deleteTodo(id: string) {
  if (!(await canEditTodo(id))) {
    throw new Error('Forbidden: You cannot delete this todo')
  }

  await prisma.todo.delete({
    where: { id },
  })

  revalidatePath('/')
}

export async function getTodos() {
  const user = await requireAuth()
  const admin = await isAdmin()

  const todos = await prisma.todo.findMany({
    where: admin ? {} : { createdById: user.id },
    include: {
      assignee: true,
      createdBy: {
        select: {
          name: true,
          email: true,
        },
      },
    },
    orderBy: {
      createdAt: 'desc',
    },
  })

  return todos
}

requireAuth()で認証チェックし、canEditTodo()で権限チェック。管理者は全てのTodoを操作でき、通常ユーザーは自分のTodoだけ操作できる、という仕組みです。Server Actionの先頭で認証チェックを行うことで、セキュアなAPIを簡単に実装できます。

ハマったポイントと解決方法

"User email not found in Clerk session" エラー

最初、auth()で取得したセッション情報から直接メールアドレスを取得しようとしましたが、うまく取れませんでした。

// ❌ 間違った実装
const clerkUser = await auth()
const userEmail = clerkUser.sessionClaims?.email

正しくは、clerkClientを使ってユーザー情報を取得する必要があります。

// ✅ 正しい実装
const client = await clerkClient()
const clerkUser = await client.users.getUser(userId)

const userEmail = clerkUser.emailAddresses.find(
  (email) => email.id === clerkUser.primaryEmailAddressId
)?.emailAddress

この部分は少し迷いましたが、公式ドキュメントを確認して解決しました。開発環境では詳細なログが出るので、デバッグもしやすかったです👍

Clerkのセッション情報とユーザー詳細情報は別々のAPIで取得する必要があることを理解しておくと、同様のトラブルを避けられると思います。

まとめ

Clerk認証をNext.js 16アプリに導入し、以下を実装しました:

  • Clerk認証の基本セットアップ
  • Google OAuthとEmail/Password認証
  • Middlewareによるルート保護
  • ClerkユーザーとローカルDBの自動同期
  • ロールベースアクセス制御(USER/ADMIN)
  • Server Actionでの認証・認可
  • ナビゲーションUIの実装

Clerkを使うと、認証周りの複雑な処理を自分で実装する必要がなく、かなり楽に認証機能を追加できました。特にUIコンポーネントが洗練されていて、開発体験がとても良かったです。セキュリティ面でも信頼できるサービスなので、安心して本番環境で使用できそうです。

既存のアプリケーションへの導入も、今回紹介したDBとの同期方法を使えばスムーズに行えると感じました。これからNext.jsで認証を実装する方の参考になれば幸いです!

参考リンク

https://clerk.com/docs

https://clerk.com/docs/references/nextjs/overview

https://dashboard.clerk.com/

株式会社StellarCreate | Tech blog📚

Discussion