Benutzerverwaltung

Benutzerprofile verwalten, Clerk-Daten mit der Datenbank synchronisieren und Benutzer-Tiers mit Credits verwalten

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.

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

TierCredits / MonatTypische Nutzung
free500Standard für neue Registrierungen. Grundlegender Feature-Zugriff.
basic1.500Einstiegs-Bezahltier.
pro5.000Vollständiger Feature-Zugriff.
enterprise15.000Individuelle 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.

Free-Tier-Initialisierung

Wenn sich ein Benutzer registriert, initialisiert der Clerk-Webhook seinen Datenbankdatensatz mit:
  • tier: 'free'
  • creditBalance: Wert aus NEXT_PUBLIC_CREDIT_FREE_TIER_CREDITS (Standard: 500)
  • creditsPerMonth: Gleich wie creditBalance
  • creditsResetAt: Aktuelles Datum + 30 Tage
  • bonusCredits: 0
  • bonusCreditsAutoUse: 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 UserButton aus @clerk/nextjs dynamisch, nur wenn Clerk aktiv ist
  • Zeigt ein Lade-Skeleton während des dynamischen Imports
  • Rendert TestUserButton im 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:
ButtonE-MailTierCredits
Freefree@test.example.comfree500
Basicbasic@test.example.combasic1.500
Propro@test.example.compro5.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:
KomponenteErsetztVerhalten
TestSignInClerk SignInDeaktiviertes Formular mit „Test Environment"-Hinweis
TestSignUpClerk SignUpDeaktiviertes Formular mit „Test Environment"-Hinweis
TestUserButtonClerk UserButtonAvatar mit E-Mail-Anzeige
TestSignOutButtonClerk SignOutButtonAbmelde-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:
  1. Middleware gibt NextResponse.next() für alle Routen zurück (keine Auth-Prüfungen)
  2. ClerkProvider wird nicht gerendert (kein SDK-Overhead)
  3. Auth-Hooks geben Test-Benutzerdaten zurück
  4. 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.
typescript
// Example: Vitest mock for Clerk
vi.mock('@clerk/nextjs', () => ({
  useAuth: () => ({ isSignedIn: true, userId: 'test-user' }),
  useUser: () => ({ user: { firstName: 'Test' } }),
}))