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ダッシュボードでアプリを作成し、必要な認証キーを取得します。
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はシンプルで洗練されているので、カスタマイズの手間が省けるのが嬉しいポイントです。
ナビゲーションには、useAuthと UserButton、SignInButtonを使います。
// 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で認証を実装する方の参考になれば幸いです!
参考リンク
株式会社StellarCreate(stellar-create.co.jp)のエンジニアブログです。 プロダクト指向のフルスタックエンジニアを目指す方募集中です! カジュアル面談で気軽に雑談しましょう!→ recruit.stellar-create.co.jp/
Discussion