Skip to content

Syncing Users Between Clerk and Your Database in Next.js

By Published

When Clerk handles authentication, your application still has to decide where its own user data belongs. A valid session and a row in your database are different things. Signing in does not mean that an asynchronous webhook has already created that row.

The useful question is not which synchronization mechanism wins. It is which operations need a local user immediately, which data may arrive later, and what happens when the two systems disagree.

Decide what your database needs to own

An application may need a local user record to own projects, join a team, or relate to an order. Keep that application data in your database. Clerk remains responsible for authentication. If the session already contains everything a feature needs, a separate synchronized profile may be unnecessary.

Use the provider’s stable user ID as the identity key. An email address can change, and not every authentication flow supplies one. Email and display name can be optional profile fields; they should not silently become the key linking an account to application records.

TypeScript
// db/schema.ts — application identity, with optional profile data
import {pgTable, text, timestamp, uuid} from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  clerkId: text('clerk_id').unique().notNull(),
  email: text('email'),
  name: text('name'),
  deletedAt: timestamp('deleted_at', {withTimezone: true}),
  createdAt: timestamp('created_at', {withTimezone: true}).defaultNow().notNull(),
})

This is the application-identity table, not a complete synchronization schema. A production webhook processor also needs durable receipt and version information. The profile fields are display data; a copied email or role is not sufficient proof of authorization.

Ensure the local identity where it is needed

A post-login page can provide a useful setup experience. It cannot enforce a database invariant by itself: a user may follow a deep link or call an endpoint directly. Put the check in the server-side application boundary used by the protected operation.

Creating the row is a mutation. Run that work in a deliberate setup or write operation, rather than making an ordinary rendering read quietly create users. A read-only page can check for the row and direct the user to setup when it is missing.

The database constraint must settle concurrent creation. Two requests can both observe a missing row. A read followed by an ordinary insert still has a race; checking first does not make it safe.

TypeScript
// lib/application-user.ts
import 'server-only'
import {auth} from '@clerk/nextjs/server'
import {eq} from 'drizzle-orm'
import {db} from '@/db'
import {users} from '@/db/schema'

export async function requireApplicationUser() {
  const {userId} = await auth()
  if (!userId) throw new Error('Authentication required')

  const [existing] = await db.select().from(users)
    .where(eq(users.clerkId, userId)).limit(1)

  if (existing) {
    if (existing.deletedAt) throw new Error('Account unavailable')
    return existing
  }

  await db.insert(users).values({clerkId: userId})
    .onConflictDoNothing({target: users.clerkId})

  const [created] = await db.select().from(users)
    .where(eq(users.clerkId, userId)).limit(1)

  if (!created || created.deletedAt) throw new Error('Account unavailable')
  return created
}

The unique constraint and Drizzle’s conflict handling allow a competing request to create the row without turning the second insert into an application error. The final read retrieves the winning row. No email or name is required to establish the local identity.

This function establishes a local identity; it does not authorize access to every record. Each action must still check the relevant membership, permission, or ownership. An account that becomes unavailable during a sensitive operation also needs to be rejected at that operation’s write boundary.

If you redirect after setup, call redirect() outside a catch block intended for database errors. Next.js implements the redirect by throwing; a broad catch can turn successful navigation into a setup error.

Use webhooks for changes that happen later

A synchronous check handles the immediate need for a local row. Webhooks handle profile changes and account lifecycle events without relying on a return visit. Clerk documents user.created, user.updated, and user.deleted for this work. A change to the user’s primary email belongs to the user update flow, not an invented email.updated handler.

The endpoint must accept the provider’s request without a browser session and verify its signature before processing it. Keep signature rejection separate from a failure to persist a valid event.

TypeScript
// app/api/webhooks/clerk/route.ts — handler boundary
import {verifyWebhook} from '@clerk/nextjs/webhooks'
import type {NextRequest} from 'next/server'
import {applyVerifiedUserEvent} from '@/lib/user-events'

export async function POST(req: NextRequest) {
  let event: Awaited<ReturnType<typeof verifyWebhook>>
  try {
    event = await verifyWebhook(req)
  } catch {
    return new Response('Invalid webhook', {status: 400})
  }

  if (!['user.created', 'user.updated', 'user.deleted'].includes(event.type)) {
    return new Response(null, {status: 200})
  }

  const deliveryId = req.headers.get('svix-id')
  if (!deliveryId) return new Response('Missing delivery ID', {status: 400})

  try {
    await applyVerifiedUserEvent({deliveryId, event})
  } catch {
    return new Response('Processing failed', {status: 500})
  }

  return new Response(null, {status: 200})
}

The SDK reads the signing secret from CLERK_WEBHOOK_SIGNING_SECRET. Pass the original request to its verification helper. The handler above is an integration boundary, not a complete app: applyVerifiedUserEvent is the application-specific database processor described below.

A success response means the work is durable. Return it after committing the database change, or after accepting the event into a durable queue. Returning 200 after an unrecorded processing failure tells the provider it can stop retrying. An in-memory background task is not a durable handoff.

Make replay, ordering, and deletion explicit

Treat the processor as a small state machine, rather than three unrelated callbacks. Clerk’s delivery documentation describes duplicate deliveries and events arriving out of order. Those are conditions to design for, not exceptional accidents.

Record the event’s delivery ID with the state change in one database transaction. A committed receipt can make a repeated delivery a no-op. If the transaction fails, neither the receipt nor a partial change should be treated as completed. External side effects, such as sending email, need their own durable job or outbox handling.

A delivery ID prevents repeating the same event. It does not prove that a different event is newer. Store and compare the provider’s ordering information before overwriting profile data; define the rule for equal versions too. Keep profile refresh separate from application-owned fields such as team membership.

Keep a deletion marker rather than immediately forgetting the identity. Late create or update events must not recreate an account that your lifecycle policy has made unavailable. The synchronous helper must respect the same marker. A deliberate restore or identity-merge flow should be a separate operation.

Deletion across Clerk and your database is not one transaction. Record a pending operation, retry incomplete work, and expose failure to an operator. Deleting locally, ignoring a provider failure, and reporting success can leave an account active in one system and absent in the other.

Test the transitions, not just the happy path

Start with two first requests for the same identity: both should obtain the same application user, and only one row should exist. Then deliver the same event twice and verify that its side effect happens once.

Test a delayed update after a newer update, an update after deletion, and a database failure before commit. A transient failure must remain retryable. A deleted account must remain unavailable. A user who skips the setup screen must still encounter the server-side checks.

For permissions, call the protected action directly with another user’s record ID. Passing authentication or visiting the correct page is not proof that this operation is allowed.

These failure cases make a useful review checklist when implementing with AI agents. Ask the tests to establish the behavior you need before accepting a plausible-looking synchronization helper.

The aim is a small, understandable contract: authenticated identity at the request boundary, application invariants in the database, and asynchronous changes that can be replayed and recovered without quietly overwriting newer state.

First published March 15, 2025. Technically revised October 26, 2026: concurrent creation, webhook responses, event names, authorization, and deletion handling replace unsafe examples in the original version. The snippets illustrate boundaries and require application-specific persistence and lifecycle policies.