Skip to main content

Clerk Integration

Implement Clerk authentication integration patterns including user invitations, invitation lifecycle, and domain error classes. Use when integrating Clerk auth into Next.js backends, handling invitation flows, or writing tests for Clerk-protected endpoints.

Clerk Integration

Master Clerk authentication integration for Next.js backends including invitation flows, webhook-driven user lifecycle, domain error classes, and Vitest mocking patterns.

Scope: This skill covers backend Clerk integration — server-side API routes, invitation management, user lookups, and webhook handlers. It is NOT about browser-level testing of Clerk-protected pages. For that, see webapp-testing.

Version target: @clerk/nextjs v5+ — patterns verified against v5/v6 (async clerkClient() factory). Review Clerk's migration guide if your project uses v4 or earlier.

When to Use This Skill

  • Implementing user invitation flows (invite-only onboarding, team member invites)
  • Writing server-side Clerk API calls (user lookup, invitation creation)
  • Understanding the pending-to-active user lifecycle after invitation acceptance
  • Defining domain error classes for Clerk failures
  • Testing server actions or route handlers that call Clerk APIs
  • Resolving race conditions between invitation acceptance and database writes

Core Concepts

1. Clerk Backend Client

Use clerkClient() from @clerk/nextjs/server (async factory) to access all Clerk backend APIs:

  • clerk.users.getUserList({ emailAddress }) — check for existing users
  • clerk.invitations.createInvitation({ emailAddress, ignoreExisting }) — send invitation
  • clerk.users.getUser(userId) — fetch user by ID

2. Pending User ID Convention

Users who have been invited but haven't accepted yet don't have a real Clerk user ID. Store them as:

pending_${invitation.id}

When the user.created webhook fires after acceptance, resolve the pending ID to the real clerkUserId.

3. Error Handling

Clerk throws structured API errors. Use isClerkAPIResponseError from @clerk/nextjs/errors to distinguish Clerk errors from unexpected errors, then access error.errors[0]?.longMessage for structured messages.

4. Idempotency

createInvitation with ignoreExisting: true is safe to call multiple times for the same email — it won't throw if an invitation already exists.

Patterns

Pattern 1: Check-Before-Invite

Always check for an existing Clerk user before sending an invitation. Existing users should be linked directly; only new emails need an invitation.

// input assumed from outer scope
const clerk = await clerkClient()
let clerkUserId: string
// isExistingClerkUser tracks whether to skip invitation email
// downstream logic (e.g., welcome email, audit log) may branch on this flag
let isExistingClerkUser = false

const { data: existingUsers } = await clerk.users.getUserList({
  emailAddress: [input.email],
})

if (existingUsers.length > 0) {
  clerkUserId = existingUsers[0].id
  isExistingClerkUser = true
} else {
  const invitation = await clerk.invitations.createInvitation({
    emailAddress: input.email,
    ignoreExisting: true,
  })
  clerkUserId = `pending_${invitation.id}`
}

Pattern 2: Clerk API Error Handling

Wrap Clerk API calls with isClerkAPIResponseError to surface structured error messages and rethrow domain errors cleanly.

// logger, tenantId, input, and clerkUserId assumed from outer scope
import { isClerkAPIResponseError } from "@clerk/nextjs/errors"

try {
  const invitation = await clerk.invitations.createInvitation({
    emailAddress: input.email,
    ignoreExisting: true,
  })
  clerkUserId = `pending_${invitation.id}`
} catch (error) {
  if (isClerkAPIResponseError(error)) {
    logger.error("Clerk invitation API error", {
      tenantId,
      email: input.email,
      error: error.errors[0]?.longMessage ?? error.errors[0]?.message,
    })
    throw new ClerkInvitationError("Invitation service unavailable")
  }
  throw error
}

Pattern 3: Domain Error Classes

Define typed error classes for each distinct Clerk failure mode. This enables precise HTTP status mapping in route handlers.

export class DuplicateEmailError extends Error {
  constructor(email: string) {
    super(`An admin user with email ${email} already exists in this tenant`)
    this.name = "DuplicateEmailError"
  }
}

export class ClerkInvitationError extends Error {
  constructor(message: string) {
    super(message)
    this.name = "ClerkInvitationError"
  }
}

Pattern 4: Pending User ID Convention — DB Write

Store the pending user record immediately after creating the invitation so the user is tracked before they accept. The pending_ prefix makes invitation state queryable — any clerkUserId starting with pending_ hasn't been resolved to a real Clerk user yet.

// input, tenantId, prisma assumed from outer scope
// After creating the invitation (see Pattern 1):
const invitation = await clerk.invitations.createInvitation({
  emailAddress: input.email,
  ignoreExisting: true,
})

// Write the DB record immediately with the pending ID.
// Guards: check for existing records first (Pattern 5 handles concurrent writes).
await prisma.adminUser.create({
  data: {
    email: input.email,
    clerkUserId: `pending_${invitation.id}`,  // resolved on user.created webhook
    tenantId,
  },
})

To query unresolved invitations:

const pendingUsers = await prisma.adminUser.findMany({
  where: { clerkUserId: { startsWith: "pending_" } },
})

The webhook handler that resolves pending_ IDs to real Clerk user IDs is deferred — see the Deferred note at the bottom of this skill.

Pattern 5: Race Condition Handling (Prisma P2002)

When two requests invite the same email concurrently, the second will hit a unique constraint. Catch Prisma P2002 and surface it as a domain error.

// prisma, input, clerkUserId, tenantId, DuplicateEmailError assumed from outer scope
import { Prisma } from "@prisma/client"

try {
  await prisma.adminUser.create({
    data: { email: input.email, clerkUserId, tenantId },
  })
} catch (error) {
  if (
    error instanceof Prisma.PrismaClientKnownRequestError &&
    error.code === "P2002"
  ) {
    throw new DuplicateEmailError(input.email)
  }
  throw error
}

Pattern 6: Route Handler Error Mapping

Map domain errors to appropriate HTTP status codes in Next.js route handlers. Clerk invitation failures return 502 (upstream service error), duplicates return 409.

Auth required: An auth() check must precede this block. This pattern assumes an authenticated, admin-scoped endpoint. See standards/core/security.md for the required auth guard pattern.

Email in 409 response: DuplicateEmailError.message includes the email address. Returning it in the response body is acceptable for authenticated admin endpoints (the admin typed the email). Do not apply this pattern to unauthenticated endpoints — it creates an email enumeration vector.

// inviteAdminUser, input, DuplicateEmailError, ClerkInvitationError, logger assumed from outer scope
import { NextResponse } from "next/server"

// Auth guard must come first — example:
// const { userId, orgId } = await auth()
// if (!userId || !orgId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })

try {
  await inviteAdminUser(input)
  return NextResponse.json({ success: true })
} catch (error) {
  if (error instanceof DuplicateEmailError) {
    return NextResponse.json({ error: error.message }, { status: 409 })
  }
  if (error instanceof ClerkInvitationError) {
    return NextResponse.json({ error: error.message }, { status: 502 })
  }
  logger.error("Unexpected error in invite route", { error })
  return NextResponse.json(
    { error: "Internal server error" },
    { status: 500 }
  )
}

Pattern 7: Testing with Clerk Mocks

Mock @clerk/nextjs/server and @clerk/nextjs/errors in Vitest to isolate unit tests from the real Clerk API.

vi.mock("@clerk/nextjs/server", () => ({
  clerkClient: vi.fn().mockResolvedValue({
    invitations: {
      createInvitation: vi.fn().mockResolvedValue({ id: "inv_123" }),
    },
    users: {
      getUserList: vi.fn().mockResolvedValue({ data: [] }),
    },
  }),
}))

vi.mock("@clerk/nextjs/errors", () => ({
  isClerkAPIResponseError: vi.fn().mockReturnValue(false),
}))

// To test the Clerk API error path:
import { isClerkAPIResponseError } from "@clerk/nextjs/errors"
vi.mocked(isClerkAPIResponseError).mockReturnValue(true)

Best Practices

  1. Always check for existing users first — Use Pattern 1 to avoid sending redundant invitations to users who already have a Clerk account
  2. Use ignoreExisting: true — Makes invitation creation idempotent; safe to retry
  3. Define domain error classes — Never surface raw Clerk errors to HTTP clients; map to typed domain errors (Pattern 3)
  4. Map HTTP status codes precisely — 409 for duplicates, 502 for upstream Clerk failures, 500 for unexpected errors
  5. Mock both Clerk modules in tests — Always mock both @clerk/nextjs/server AND @clerk/nextjs/errors; forgetting the errors mock causes isClerkAPIResponseError to return incorrect values in tests
  6. Resolve pending IDs in webhooks — Never leave pending_ IDs in production permanently; always resolve them in user.created webhook handlers
  7. Log structured Clerk errors — Use error.errors[0]?.longMessage for detailed Clerk error messages in logs

Common Pitfalls

  • Not checking for existing users: Sending invitations to already-registered users causes confusion and unnecessary emails
  • Missing ignoreExisting: true: Without this flag, createInvitation throws if an invitation is already pending for that email
  • Swallowing non-Clerk errors: In the catch block, always throw error after the Clerk-specific branch — don't silently swallow unexpected errors
  • Forgetting @clerk/nextjs/errors mock: If isClerkAPIResponseError isn't mocked, your Clerk error handling branch won't be testable
  • Not handling P2002 race condition: Concurrent invitations to the same email will cause unhandled Prisma errors without Pattern 5

Deferred: Webhook resolution pattern (resolving pending_ IDs to real Clerk user IDs via user.created webhook) — not yet implemented in any project. Will be added when a project implements this flow. Until then, webhook handlers MUST verify svix signatures per standards/core/security.md § Webhook Security.

Search Framework Explorer

Search agents, skills, and standards