Kit maintains user data in two places: Clerk handles identity (email, password, social accounts, MFA) and your database stores application data (tier, credits, subscriptions, preferences). This page explains how these two systems stay in sync and how you access user data throughout your application.
User Sync Flow
Users enter your system through Clerk (sign-up, social login) but your application logic depends on database records. Kit bridges this gap with two sync mechanisms.
Webhook-Based Sync
When a user signs up through Clerk, a
user.created webhook fires automatically. The handler at /api/webhooks/clerk creates the corresponding database record:User signs up via Clerk
|
v
Clerk fires "user.created" webhook
|
v
/api/webhooks/clerk/route.ts
|--- Verify SVIX signature
|--- Extract email, name from event data
|--- prisma.user.upsert (idempotent)
|--- Initialize: tier="free", credits=500, resetAt=+30 days
|
v
User exists in both Clerk and your database
This is the primary sync path. It handles the initial creation and ensures every Clerk user has a database counterpart. The
user.updated and user.deleted events keep the records in sync throughout the user's lifecycle.On-Demand Sync
Sometimes the webhook might not have fired yet (race condition on first login) or the database record might be stale. Kit provides two functions for on-demand synchronization:
syncUserFromClerk(clerkId) — Fetches user data from Clerk's API and upserts it into your database:src/lib/auth/sync-user.ts
export async function syncUserFromClerk(clerkId: string): Promise<User | null> {
// Skip Clerk API in test environment (E2E tests, CI)
// Test users are seeded in playwright/seed-test-data.ts
if (!shouldUseClerk()) {
console.log(`[TEST] Skipping Clerk sync for ${clerkId}`)
return null
}
try {
// Get user data from Clerk
const clerkUser = await (await clerkClient()).users.getUser(clerkId)
if (!clerkUser) {
console.error(`Clerk user not found: ${clerkId}`)
return null
}
// Extract email and name
const email = clerkUser.emailAddresses[0]?.emailAddress || null
const name =
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(' ') ||
null
// Upsert user in database
const user = await prisma.user.upsert({
where: { clerkId },
update: {
email,
name,
},
create: {
clerkId,
email,
name,
},
})
console.log(`User ${clerkId} synced from Clerk`)
return user
} catch (error) {
console.error('Error syncing user from Clerk:', error)
return null
}
}
ensureUserExists(clerkId) — First checks the database, then syncs from Clerk only if the record is missing or incomplete:src/lib/auth/sync-user.ts
export async function ensureUserExists(clerkId: string): Promise<User | null> {
// ⚠️ CRITICAL: In E2E tests, return mock user to prevent database queries
// MSW mocks handle all API responses in test environment
// Note: Check NEXT_PUBLIC_CLERK_ENABLED instead of NODE_ENV because
// the dev server runs with NODE_ENV=development even during tests
if (process.env.NEXT_PUBLIC_CLERK_ENABLED === 'false') {
return {
id: 'test_user_id',
clerkId,
email: 'test@example.com',
name: 'Test User',
createdAt: new Date(),
updatedAt: new Date(),
} as User
}
try {
// First try to get user from database using repository pattern
let user = await userRepository.findByClerkId(clerkId)
// If user doesn't exist or has no email, sync from Clerk
if (!user || !user.email) {
user = await syncUserFromClerk(clerkId)
}
return user
} catch (error) {
console.error('Error ensuring user exists:', error)
return null
}
}
Use
ensureUserExists() when you need a user record and want to handle the edge case where the webhook has not arrived yet. It checks the database first (fast) and only calls the Clerk API if necessary.Both functions are environment-aware. In test mode (
NEXT_PUBLIC_CLERK_ENABLED=false), they skip Clerk API calls and return mock data. This prevents external API calls during tests and CI runs.Database User Provider
Once the dashboard layout resolves the current user, it needs to make the database user ID available to all child components. Kit uses a React context provider for this.
How It Works
The
DbUserProvider is a simple context provider that wraps the entire dashboard:src/providers/db-user-provider.tsx
'use client'
import { createContext, useContext } from 'react'
/**
* Database User Context
* Provides database user ID to all dashboard components
* This avoids prop drilling and makes userId available everywhere
*/
interface DbUserContextValue {
userId: string | null
}
const DbUserContext = createContext<DbUserContextValue | undefined>(undefined)
interface DbUserProviderProps {
userId: string | null
children: React.ReactNode
}
/**
* DbUserProvider
* Wraps dashboard layout to provide database user ID to all child components
*
* @example
* // In layout:
* <DbUserProvider userId={dbUserId}>
* <DashboardContent />
* </DbUserProvider>
*/
export function DbUserProvider({ userId, children }: DbUserProviderProps) {
return (
<DbUserContext.Provider value={{ userId }}>
{children}
</DbUserContext.Provider>
)
}
/**
* useDbUser hook
* Access database user ID from anywhere in the dashboard
*
* @throws Error if used outside DbUserProvider
* @returns Database user ID or null
*
* @example
* const { userId } = useDbUser()
* if (!userId) return <div>Loading...</div>
* return <CreditBalanceBadge userId={userId} />
*/
export function useDbUser(): DbUserContextValue {
const context = useContext(DbUserContext)
if (context === undefined) {
throw new Error('useDbUser must be used within DbUserProvider')
}
return context
}
In the dashboard layout, the provider is initialized with the resolved user ID:
typescript
// src/app/(dashboard)/layout.tsx (simplified)
const dbUser = await userRepository.findByClerkId(clerkUserId)
return (
<DbUserProvider userId={dbUser?.id || null}>
{children}
</DbUserProvider>
)
Using useDbUser
Any component inside the dashboard can access the database user ID:
typescript
import { useDbUser } from '@/providers/db-user-provider'
function CreditBalance() {
const { userId } = useDbUser()
if (!userId) return <p>Loading...</p>
// Use userId to fetch credits, billing, etc.
return <CreditBalanceBadge userId={userId} />
}
This avoids prop drilling — you do not need to pass
userId through every component layer. The provider makes it available anywhere in the dashboard tree.User Hooks
Kit provides several hooks for accessing user data in Client Components. Each serves a different purpose.
useCurrentUser
Fetches the full user profile from the API using TanStack Query:
typescript
import { useCurrentUser } from '@/lib/query/hooks/use-user'
function ProfileCard() {
const { data: user, isLoading } = useCurrentUser()
if (isLoading) return <Skeleton />
return <p>{user?.name} ({user?.email})</p>
}
This hook calls
/api/user/current and caches the result with a 10-minute stale time. The data includes name, email, tier, credits, and other database fields.useUserTier
Returns the current user's subscription tier with demo mode awareness:
typescript
import { useUserTier } from '@/lib/query/hooks/use-user-tier'
function FeatureGate({ children }: { children: React.ReactNode }) {
const { tier, isLoading } = useUserTier()
if (isLoading) return <Skeleton />
if (tier === 'free') return <UpgradePrompt />
return <>{children}</>
}
In demo mode, this hook reads the tier from
DemoContext instead of the API, allowing visitors to switch between tiers and see how the UI changes.useConditionalAuth and useConditionalUser
Environment-safe wrappers around Clerk's
useAuth() and useUser() hooks:typescript
import {
useConditionalAuth,
useConditionalUser,
} from '@/lib/auth/use-conditional-auth'
function AuthStatus() {
const { isSignedIn, userId } = useConditionalAuth()
const { user } = useConditionalUser()
return isSignedIn
? <p>Signed in as {user?.firstName}</p>
: <p>Not signed in</p>
}
These hooks call real Clerk hooks in production and return mock data in test/demo mode. Use them instead of importing directly from
@clerk/nextjs to ensure your components work in all environments.User Tier System
Kit includes a tier system that controls feature access and credit allocation.
Tier Hierarchy
| Tier | Credits / Month | Typical Use |
|---|---|---|
| free | 500 | Default for new signups. Basic feature access. |
| basic | 1,500 | Entry-level paid tier. |
| pro | 5,000 | Full feature access. |
| enterprise | 15,000 | Custom plans with dedicated support. |
Tiers are stored in the
tier column of the User model in your database. They are set by the payment system (Lemon Squeezy webhooks) when users subscribe or change plans.The tier names and credit amounts are configurable. See the Payments section for details on setting up subscription plans and the Credit System for configuring credit amounts per tier.
Free Tier Initialization
When a user signs up, the Clerk webhook initializes their database record with:
tier: 'free'creditBalance: Value fromNEXT_PUBLIC_CREDIT_FREE_TIER_CREDITS(default: 500)creditsPerMonth: Same ascreditBalancecreditsResetAt: Current date + 30 daysbonusCredits: 0bonusCreditsAutoUse: false
This gives every new user immediate access to credit-consuming features (like AI chat) without requiring a paid subscription.
The UniversalUserButton
Kit provides a
UniversalUserButton component that renders Clerk's UserButton in production and a test avatar in test/demo mode:typescript
import { UniversalUserButton } from '@/components/auth/universal-user-button'
function Header() {
return (
<nav>
<UniversalUserButton />
</nav>
)
}
The component:
- Dynamically imports
UserButtonfrom@clerk/nextjsonly when Clerk is active - Shows a loading skeleton during the dynamic import
- Renders
TestUserButtonin test mode (displays the test user's email) - Handles all environments without conditional logic in your pages
This component is already used in the dashboard sidebar and mobile header. Use it anywhere you need a user avatar with a dropdown menu.
Demo Mode Authentication
Kit includes a demo mode for public deployments where visitors can explore the application without creating accounts. Enable it with:
bash
NEXT_PUBLIC_DEMO_MODE=true
DemoLoginForm
When demo mode is active, the login page shows a custom
DemoLoginForm instead of Clerk's sign-in component. This form displays:- A demo mode banner making it clear this is not a real account
- Quick-login buttons for each tier (Free, Basic, Pro) with pre-filled credentials
- Manual login fields for entering custom credentials
Tier-Based Quick Login
Each quick-login button logs in as a pre-seeded user with a specific tier:
| Button | Tier | Credits | |
|---|---|---|---|
| Free | free@test.example.com | free | 500 |
| Basic | basic@test.example.com | basic | 1,500 |
| Pro | pro@test.example.com | pro | 5,000 |
This lets visitors see how the dashboard, billing page, and feature gates look at different subscription levels — without setting up real Clerk accounts or payment methods.
Testing Authentication
Kit provides comprehensive test utilities so your tests never depend on a live Clerk instance.
Test Helpers
The
@/lib/auth/test-helpers module exports mock versions of all Clerk hooks and components:typescript
// Mock hooks (identical interface to Clerk)
useTestAuth() // { isLoaded: true, isSignedIn: true, userId: 'clerk_test_free_001', ... }
useTestUser() // { isLoaded: true, isSignedIn: true, user: { id, email, firstName, ... } }
useTestClerk() // { loaded: true, signOut, openSignIn, openSignUp, ... }
These mocks return stable, predictable data that matches the seeded test database. The test user
clerk_test_free_001 exists in the Playwright seed data, ensuring all features work correctly in tests.Mock components are also available:
| Component | Replaces | Behavior |
|---|---|---|
TestSignIn | Clerk SignIn | Disabled form with "Test Environment" notice |
TestSignUp | Clerk SignUp | Disabled form with "Test Environment" notice |
TestUserButton | Clerk UserButton | Avatar with email display |
TestSignOutButton | Clerk SignOutButton | Logout button wrapper |
E2E Testing
For end-to-end tests with Playwright, Kit uses environment-based auth bypass:
bash
# .env.test (used during E2E test runs)
NEXT_PUBLIC_CLERK_ENABLED=false
With this flag, the entire auth system switches to test mode:
- Middleware returns
NextResponse.next()for all routes (no auth checks) - ClerkProvider is not rendered (no SDK overhead)
- Auth hooks return test user data
- Database queries use the seeded test user
This approach is faster and more reliable than mocking Clerk's HTTP endpoints. Your E2E tests exercise real application logic — routing, data fetching, UI rendering — without depending on an external auth service.
For unit tests with Vitest, mock Clerk at the module level with
vi.mock('@clerk/nextjs', ...). The conditional hooks (useConditionalAuth) handle this automatically in most cases, but direct mocking gives you control over specific test scenarios.typescript
// Example: Vitest mock for Clerk
vi.mock('@clerk/nextjs', () => ({
useAuth: () => ({ isSignedIn: true, userId: 'test-user' }),
useUser: () => ({ user: { firstName: 'Test' } }),
}))