Kit pflegt Benutzerdaten an zwei Stellen: Clerk verwaltet die Identität (E-Mail, Passwort, Social Accounts, MFA) und deine Datenbank speichert Anwendungsdaten (Tier, Credits, Abonnements, Einstellungen). Diese Seite erklärt, wie diese beiden Systeme synchron bleiben und wie du in deiner gesamten Anwendung auf Benutzerdaten zugreifst.
Benutzer-Sync-Flow
Benutzer gelangen über Clerk in dein System (Registrierung, Social Login), aber deine Anwendungslogik hängt von Datenbankdatensätzen ab. Kit überbrückt diese Lücke mit zwei Synchronisierungsmechanismen.
Webhook-basierte Synchronisierung
Wenn sich ein Benutzer über Clerk registriert, wird automatisch ein
user.created-Webhook ausgelöst. Der Handler unter /api/webhooks/clerk erstellt den entsprechenden Datenbankdatensatz: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
Das ist der primäre Sync-Pfad. Er übernimmt die initiale Erstellung und stellt sicher, dass jeder Clerk-Benutzer ein Datenbank-Gegenstück hat. Die Events
user.updated und user.deleted halten die Datensätze während des gesamten Benutzer-Lebenszyklus synchron.On-Demand-Synchronisierung
Manchmal wurde der Webhook noch nicht ausgelöst (Race Condition beim ersten Login) oder der Datenbankdatensatz ist veraltet. Kit bietet zwei Funktionen für die bedarfsgesteuerte Synchronisierung:
syncUserFromClerk(clerkId) — Ruft Benutzerdaten von der Clerk-API ab und führt ein Upsert in deine Datenbank durch: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) — Prüft zuerst die Datenbank und synchronisiert nur dann von Clerk, wenn der Datensatz fehlt oder unvollständig ist: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
}
}
Verwende
ensureUserExists(), wenn du einen Benutzerdatensatz benötigst und den Randfall abdecken möchtest, dass der Webhook noch nicht angekommen ist. Die Datenbankprüfung erfolgt zuerst (schnell) und die Clerk-API wird nur aufgerufen, wenn nötig.Beide Funktionen sind umgebungsbewusst. Im Test-Modus (
NEXT_PUBLIC_CLERK_ENABLED=false) überspringen sie Clerk-API-Aufrufe und geben Mock-Daten zurück. Das verhindert externe API-Aufrufe während Tests und CI-Läufen.Datenbankbenutzer-Provider
Sobald das Dashboard-Layout den aktuellen Benutzer aufgelöst hat, muss die Datenbankbenutzer-ID allen Kind-Komponenten zur Verfügung gestellt werden. Kit verwendet dafür einen React-Kontext-Provider.
Funktionsweise
Der
DbUserProvider ist ein einfacher Kontext-Provider, der das gesamte Dashboard umschließt: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
}
Im Dashboard-Layout wird der Provider mit der aufgelösten Benutzer-ID initialisiert:
typescript
// src/app/(dashboard)/layout.tsx (simplified)
const dbUser = await userRepository.findByClerkId(clerkUserId)
return (
<DbUserProvider userId={dbUser?.id || null}>
{children}
</DbUserProvider>
)
useDbUser verwenden
Jede Komponente innerhalb des Dashboards kann auf die Datenbankbenutzer-ID zugreifen:
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} />
}
Das vermeidet Prop-Drilling – du musst
userId nicht durch jede Komponentenschicht weitergeben. Der Provider macht sie überall im Dashboard-Baum verfügbar.Benutzer-Hooks
Kit bietet verschiedene Hooks für den Zugriff auf Benutzerdaten in Client Components. Jeder dient einem anderen Zweck.
useCurrentUser
Ruft das vollständige Benutzerprofil über die API mit TanStack Query ab:
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>
}
Dieser Hook ruft
/api/user/current auf und cached das Ergebnis mit einer 10-minütigen Stale-Time. Die Daten umfassen Name, E-Mail, Tier, Credits und andere Datenbankfelder.useUserTier
Gibt das aktuelle Abonnement-Tier des Benutzers mit Demo-Modus-Bewusstsein zurück:
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}</>
}
Im Demo-Modus liest dieser Hook das Tier aus
DemoContext statt aus der API, sodass Besucher zwischen Tiers wechseln und sehen können, wie sich die UI verändert.useConditionalAuth und useConditionalUser
Umgebungssichere Wrapper um Clerks
useAuth()- und 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>
}
Diese Hooks rufen echte Clerk-Hooks in der Produktion auf und geben im Test-/Demo-Modus Mock-Daten zurück. Verwende sie statt direkter Imports aus
@clerk/nextjs, um sicherzustellen, dass deine Komponenten in allen Umgebungen funktionieren.Benutzer-Tier-System
Kit enthält ein Tier-System, das den Feature-Zugriff und die Credit-Zuteilung steuert.
Tier-Hierarchie
| Tier | Credits / Monat | Typische Nutzung |
|---|---|---|
| free | 500 | Standard für neue Registrierungen. Grundlegender Feature-Zugriff. |
| basic | 1.500 | Einstiegs-Bezahltier. |
| pro | 5.000 | Vollständiger Feature-Zugriff. |
| enterprise | 15.000 | Individuelle Pläne mit dediziertem Support. |
Tiers werden in der
tier-Spalte des User-Modells in deiner Datenbank gespeichert. Sie werden vom Zahlungssystem (Lemon Squeezy-Webhooks) gesetzt, wenn Benutzer ein Abonnement abschließen oder den Plan wechseln.Tier-Namen und Credit-Beträge sind konfigurierbar. Weitere Details zur Einrichtung von Abonnementplänen findest du im Abschnitt Zahlungen und zur Konfiguration von Credit-Beträgen pro Tier im Abschnitt Credit-System.
Free-Tier-Initialisierung
Wenn sich ein Benutzer registriert, initialisiert der Clerk-Webhook seinen Datenbankdatensatz mit:
tier: 'free'creditBalance: Wert ausNEXT_PUBLIC_CREDIT_FREE_TIER_CREDITS(Standard: 500)creditsPerMonth: Gleich wiecreditBalancecreditsResetAt: Aktuelles Datum + 30 TagebonusCredits: 0bonusCreditsAutoUse: false
Das gibt jedem neuen Benutzer sofortigen Zugriff auf credit-verbrauchende Features (wie AI-Chat), ohne ein bezahltes Abonnement zu erfordern.
Der UniversalUserButton
Kit bietet eine
UniversalUserButton-Komponente, die Clerks UserButton in der Produktion und einen Test-Avatar im Test-/Demo-Modus rendert:typescript
import { UniversalUserButton } from '@/components/auth/universal-user-button'
function Header() {
return (
<nav>
<UniversalUserButton />
</nav>
)
}
Die Komponente:
- Importiert
UserButtonaus@clerk/nextjsdynamisch, nur wenn Clerk aktiv ist - Zeigt ein Lade-Skeleton während des dynamischen Imports
- Rendert
TestUserButtonim Test-Modus (zeigt die E-Mail des Test-Benutzers an) - Behandelt alle Umgebungen ohne konditionale Logik in deinen Seiten
Diese Komponente wird bereits in der Dashboard-Seitenleiste und dem mobilen Header verwendet. Nutze sie überall, wo du einen Benutzer-Avatar mit Dropdown-Menü benötigst.
Demo-Modus-Authentifizierung
Kit enthält einen Demo-Modus für öffentliche Deployments, bei denen Besucher die Anwendung erkunden können, ohne ein Konto zu erstellen. Aktiviere ihn mit:
bash
NEXT_PUBLIC_DEMO_MODE=true
DemoLoginForm
Wenn der Demo-Modus aktiv ist, zeigt die Login-Seite ein benutzerdefiniertes
DemoLoginForm anstelle von Clerks Anmeldekomponente. Dieses Formular zeigt:- Ein Demo-Modus-Banner, das deutlich macht, dass dies kein echtes Konto ist
- Schnell-Login-Buttons für jeden Tier (Free, Basic, Pro) mit vorausgefüllten Anmeldedaten
- Manuelle Login-Felder zur Eingabe eigener Anmeldedaten
Tier-basierter Schnell-Login
Jeder Schnell-Login-Button meldet sich als vorgeseedeter Benutzer mit einem bestimmten Tier an:
| 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 |
Das ermöglicht Besuchern, zu sehen, wie Dashboard, Billing-Seite und Feature-Gates bei verschiedenen Abonnementstufen aussehen – ohne echte Clerk-Konten oder Zahlungsmethoden einrichten zu müssen.
Authentifizierung testen
Kit bietet umfassende Test-Utilities, sodass deine Tests nie von einer Live-Clerk-Instanz abhängen.
Test-Helpers
Das Modul
@/lib/auth/test-helpers exportiert Mock-Versionen aller Clerk-Hooks und Komponenten: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, ... }
Diese Mocks geben stabile, vorhersehbare Daten zurück, die mit der geseedeten Test-Datenbank übereinstimmen. Der Test-Benutzer
clerk_test_free_001 existiert in den Playwright-Seed-Daten und stellt sicher, dass alle Features in Tests korrekt funktionieren.Mock-Komponenten sind ebenfalls verfügbar:
| Komponente | Ersetzt | Verhalten |
|---|---|---|
TestSignIn | Clerk SignIn | Deaktiviertes Formular mit „Test Environment"-Hinweis |
TestSignUp | Clerk SignUp | Deaktiviertes Formular mit „Test Environment"-Hinweis |
TestUserButton | Clerk UserButton | Avatar mit E-Mail-Anzeige |
TestSignOutButton | Clerk SignOutButton | Abmelde-Button-Wrapper |
E2E-Testing
Für End-to-End-Tests mit Playwright verwendet Kit einen umgebungsbasierten Auth-Bypass:
bash
# .env.test (used during E2E test runs)
NEXT_PUBLIC_CLERK_ENABLED=false
Mit diesem Flag wechselt das gesamte Auth-System in den Test-Modus:
- Middleware gibt
NextResponse.next()für alle Routen zurück (keine Auth-Prüfungen) - ClerkProvider wird nicht gerendert (kein SDK-Overhead)
- Auth-Hooks geben Test-Benutzerdaten zurück
- Datenbankabfragen verwenden den geseedeten Test-Benutzer
Dieser Ansatz ist schneller und zuverlässiger als das Mocken von Clerks HTTP-Endpunkten. Deine E2E-Tests üben echte Anwendungslogik aus – Routing, Datenabruf, UI-Rendering – ohne von einem externen Auth-Dienst abzuhängen.
Für Unit-Tests mit Vitest mockst du Clerk auf Modulebene mit
vi.mock('@clerk/nextjs', ...). Die konditionalen Hooks (useConditionalAuth) übernehmen das in den meisten Fällen automatisch, aber direktes Mocken gibt dir Kontrolle über spezifische Test-Szenarien.typescript
// Example: Vitest mock for Clerk
vi.mock('@clerk/nextjs', () => ({
useAuth: () => ({ isSignedIn: true, userId: 'test-user' }),
useUser: () => ({ user: { firstName: 'Test' } }),
}))