Skip to content

Clerk and other auth providers

With any auth provider, your app identifies and tracks where it learns that an account exists. This guide shows where, with Clerk as the worked example, then the optional webhook, other providers, and two pitfalls. It builds on Track conversions.

Choose where to record the sign-up

  • Where your app creates its own record of the account, such as its user row after sign-up: identify, then track there, as Track conversions shows.
  • With no such place, as with Clerk alone: from your server, at the new user’s first signed-in page load. Nothing is set up at the provider, but a user who never loads a signed-in page is missed.
  • Also in the provider’s user-created webhook, if you want every sign-up: it needs its endpoint added in the provider’s dashboard. Both paths use the id signup:<user id>, so a sign-up that both record counts once.

Whichever you choose, identify at every sign-in too, so that the person’s other devices link to the account.

Record the sign-up at the first signed-in page load

A client component in the root layout identifies the signed-in user at each sign-in and page load. The first time it shows a user Clerk created in the last seven days, it asks the server to record the sign-up, then remembers that user:

components/identify-user.tsx
'use client'
import { useUser } from '@clerk/nextjs'
import { useEffect } from 'react'
import { routingcat } from 'routingcat/next'
import { recordSignup } from '@/app/actions'
import { isNewUser } from '@/lib/new-user'
/**
* Links this visitor to their Clerk user at each sign-in, and on each page load while signed in.
* The first time this browser shows a user Clerk created in the last seven days, it has the server
* record the sign-up (app/actions.ts). The user is read in the browser, so the root layout stays
* static.
*/
export function IdentifyUser() {
const { user } = useUser()
const userId = user?.id
const createdAt = user?.createdAt?.getTime()
useEffect(() => {
if (!userId) return
void routingcat.identify(userId)
const key = `routingcat-signup:${userId}`
if (createdAt === undefined || !isNewUser(createdAt, Date.now()) || reported(key)) return
recordSignup().then(
(done) => {
if (done) remember(key)
},
() => {
// The call failed: the next page load asks again.
},
)
}, [userId, createdAt])
return null
}
// What this browser reported, by user. Storage can throw, blocked or full: the browser then asks at
// each page load, and the sign-up's id still keeps one sign-up.
function reported(key: string): boolean {
try {
return localStorage.getItem(key) !== null
} catch {
return false
}
}
function remember(key: string): void {
try {
localStorage.setItem(key, new Date().toISOString())
} catch {
// Not kept: asked again at the next page load.
}
}

Render <IdentifyUser /> in the root layout, inside <ClerkProvider>, beside <RoutingCat />. It reads the user in the browser, so the root layout stays static, as prerendered routed pages need.

The Server Function reads the user from Clerk and checks the age again, so a browser can only report its own user. It identifies, then tracks signup, dated when Clerk created the user:

app/actions.ts
'use server'
import { currentUser } from '@clerk/nextjs/server'
import { routingcat } from 'routingcat/next'
import { isNewUser } from '@/lib/new-user'
/**
* The sign-up, RoutingCat's objective, recorded from the server at the new user's first signed-in
* page load: components/identify-user.tsx calls it the first time a browser shows a new user, with
* nothing to set up in Clerk's dashboard. The user is read from Clerk here, so a browser reports
* only its own user, and their age is checked again. The id `signup:<user id>` keeps one sign-up
* per user, from every browser and from Clerk's optional user.created webhook alike.
*
* Returns false when no user is signed in yet, so the browser asks again at its next page load.
*/
export async function recordSignup(): Promise<boolean> {
const user = await currentUser()
if (!user) return false
if (isNewUser(user.createdAt, Date.now())) {
await routingcat.identify(user.id)
routingcat.track('signup', {
id: `signup:${user.id}`,
accountId: user.id,
// Clerk's times are Unix milliseconds; a plain number would be read as seconds.
occurredAt: new Date(user.createdAt),
})
}
return true
}
lib/new-user.ts
// Which Clerk users are new: created in the last seven days. A new user's first signed-in page load
// in a browser records their sign-up (components/identify-user.tsx, app/actions.ts); an older user
// signed up earlier, so their first visit after RoutingCat was installed records no sign-up.
/** Seven days, in milliseconds. */
export const NEW_USER_MS = 7 * 24 * 60 * 60 * 1000
/**
* Whether a user Clerk created at `createdAt` is new at `now`, both in Unix milliseconds. A creation
* time slightly ahead of the clock, as a skew between Clerk's clock and this one allows, is new.
*/
export const isNewUser = (createdAt: number, now: number): boolean => now - createdAt <= NEW_USER_MS
  • occurredAt: new Date(user.createdAt). Clerk’s times are Unix milliseconds, and RoutingCat reads a plain number as seconds, so the time goes in as a Date.
  • Seven days. A user created in the week before the install is recorded at their next signed-in page load, dated when they signed up. An older user signed up before RoutingCat, and records no sign-up.

Add the user-created webhook

Optional. A webhook carries no visitor cookie, so the sign-up page passes the visitor id to Clerk in unsafeMetadata, the only metadata Clerk lets a sign-up set:

app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from '@clerk/nextjs'
import { cookies } from 'next/headers'
/**
* Clerk's sign-up, carrying RoutingCat's visitor id to Clerk's user.created webhook, which has no
* visitor cookie. Only that optional webhook needs it: the recording at the first signed-in page
* load reads the visitor from its own request.
*/
export default async function SignUpPage() {
const visitorId = (await cookies()).get('rc_vid')?.value
return (
<main>
<SignUp unsafeMetadata={visitorId ? { rc_vid: visitorId } : undefined} />
</main>
)
}

On user.created, the webhook identifies the new user with that visitor id, then tracks signup under the same id as the first-load recording:

app/api/webhooks/clerk/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import type { NextRequest } from 'next/server'
import { routingcat } from 'routingcat/next'
/**
* Clerk's webhooks, signed with CLERK_WEBHOOK_SIGNING_SECRET: an app keeps its users in step here.
* On user.created, the sign-up, RoutingCat's objective, linked to the visitor whose id the sign-up
* page carried in unsafeMetadata. Optional: app/actions.ts records each sign-up at the user's first
* signed-in page load without it. This path also records a user who never loads one, once its
* endpoint is added in Clerk's dashboard, and the shared id `signup:<user id>` keeps one sign-up
* when both run.
*/
export async function POST(request: NextRequest) {
try {
const event = await verifyWebhook(request)
console.log(`Clerk webhook: ${event.type}`)
if (event.type === 'user.created') {
const user = event.data
const carried = user.unsafe_metadata?.rc_vid
const visitorId = typeof carried === 'string' ? carried : undefined
await routingcat.identify(user.id, { visitorId })
routingcat.track('signup', {
id: `signup:${user.id}`,
accountId: user.id,
visitorId,
occurredAt: new Date(user.created_at),
})
}
return new Response('ok')
} catch {
return new Response('Webhook verification failed', { status: 400 })
}
}

Then, in Clerk’s dashboard, add an endpoint for user.created at your site’s /api/webhooks/clerk, and set its signing secret in your environment as CLERK_WEBHOOK_SIGNING_SECRET. RoutingCat’s part of the proxy never runs on /api routes, so the webhook reaches your handler unchanged.

Record sign-ups with other providers

The same two paths work with any provider:

  • At the first signed-in page load, from your server’s own read of the signed-in user and of its creation time.
  • In its user-created webhook, with the visitor id carried through the sign-up. With Supabase, carry it in signUp’s options.data. OAuth sign-ups have no such field, so they rely on the first-load recording.

From a backend that isn’t Next.js, send the identity link and the event through the HTTP events API, as Track conversions shows.

Avoid two pitfalls

  • Verification on another device. When email verification finishes on another device, identify at account creation, on the first device: that browser is the visitor who saw your pages.
  • Callbacks posted from another site, such as Sign in with Apple’s form_post, arrive without RoutingCat’s cookies, which are SameSite=Lax, so an identify there has no visitor id. Identify at the next page load instead. status reports such calls as identify without a visitor id.

Next