- Published on
Building a Provider-Agnostic User System with Clerk and Internal User IDs
- Authors

- Name
- Jacek Smolak
- @jacek_smolak
Building a Provider-Agnostic User System with Clerk and Internal User IDs
The Problem
When building a web application with an authentication provider, like Clerk (or any other), you face a critical architectural decision: should you use the provider's user ID directly throughout your database, or create your own internal user ID system?
Using the provider's ID directly is simpler initially, but creates tight coupling. If you ever need to switch authentication providers (Clerk → Auth.js, Supabase Auth, etc.), you'd have to update user ID references across every table in your database—a nightmare for any production application with foreign key relationships.
For me this was not something I wanted to deal with in the future.
Implementing Internal User IDs
First, I needed a users table that maps Clerk's user ID to my own internal ID:
export const users = pgTable('users', {
id: ('id', { length: USER_ID_LENGTH })
.notNull()
.primaryKey()
.$defaultFn(() => generateUserId()),
clerkUserId: varchar('clerk_user_id').unique().notNull(),
// ... other fields
})
The Sync Challenge
With this architecture, I needed to ensure a user record exists in my database whenever someone authenticates. I explored three approaches:
Approach 1: Middleware on Every Request
My first attempt was adding some logic to Next.js middleware:
export default clerkMiddleware(async (auth, req) => {
const { userId } = await auth()
if (userId) {
await db
.insert(schema.users)
.values({ clerkUserId: userId })
.onConflictDoNothing({ target: schema.users.clerkUserId })
}
})
Problem: This executes a database query on every authenticated request, including all API calls. Even though onConflictDoNothing is relatively fast after the first insert, it's unnecessary overhead.
Approach 2: Client-Side Callbacks
I considered using Clerk's afterSignIn/afterSignUp callbacks to trigger user creation:
// After successful sign-in
await fetch('/api/user/create', { method: 'POST' })
Problem: Client-side callbacks can fail—network issues, user navigation, browser crashes—leading to missing user records and broken authentication.
Approach 3: Webhooks
Clerk provides server-side webhooks that fire on user lifecycle events:
// app/api/webhooks/clerk/route.ts
export async function POST(req: Request) {
const wh = new Webhook(WEBHOOK_SECRET)
const evt = wh.verify(body, headers)
if (evt.type === 'user.created') {
await db.insert(schema.users).values({ clerkUserId: evt.data.id }).onConflictDoNothing()
}
return new Response('Webhook processed', { status: 200 })
}
Benefits:
- Server-side execution (reliable)
- Runs once per user (efficient)
- Official Clerk recommendation
But what about edge cases?
- Existing users who signed up before implementing webhooks need migration
- Webhook delivery failures or endpoint downtime can leave users without database records
- New signups during deployment might miss the webhook entirely
The Final Solution
Instead of implementing webhooks with all their complexity, I realized something simpler: I'm using tRPC for all server-side database queries. That's exactly where I need the user's ID. Why not handle user creation right there?
This approach solves multiple problems at once:
- Works for existing users (no migration needed)
- No webhook infrastructure to maintain
- Handles edge cases automatically
- Only runs when actually needed (on authenticated tRPC calls)
const isAuthenticated = t.middleware(async ({ ctx, next }) => {
const { isAuthenticated, userId: clerkUserId } = ctx.auth
if (!isAuthenticated || !clerkUserId) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
// Get or create internal user record
const [user] = await ctx.db
.insert(ctx.db._.fullSchema.users)
.values({ clerkUserId })
.onConflictDoUpdate({
target: ctx.db._.fullSchema.users.clerkUserId,
set: { clerkUserId }, // No-op update to return existing record
})
.returning()
if (!user) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to get user' })
}
return next({
ctx: {
userId: user.id, // Internal userId, not Clerk's
clerkUserId,
},
})
})
export const protectedProcedure = t.procedure.use(isAuthenticated)
How It Works
The flow:
- User signs up/signs in with Clerk
- User makes an authenticated tRPC request
protectedProceduremiddleware runs:- Checks if user exists in database
- Creates user record if missing (using
onConflictDoUpdate) - Returns internal user ID to the procedure
- All tRPC procedures receive the internal user ID in context
Key benefits:
- Simple: No webhook infrastructure needed
- Reliable: User record is guaranteed to exist for any authenticated tRPC call
- Efficient: Single query using
onConflictDoUpdate(create or return existing) - No overhead: Only runs on tRPC calls, not on every page request
Summarizing
Use internal IDs when:
- You might switch auth providers
- You want provider-agnostic architecture
- Long-term flexibility > initial simplicity
Use provider IDs directly when:
- You're confident in your auth provider choice
- You prioritize simplicity
- Provider lock-in is acceptable