Database Overview

Supabase PostgreSQL with Prisma ORM — architecture, connection setup, and data access patterns

Kit uses Supabase as the hosted PostgreSQL database and Prisma as the ORM. This combination gives you type-safe database access in TypeScript, automatic migration management, and access to PostgreSQL extensions like pgvector for AI-powered semantic search.
This page covers the architecture and data access patterns. For the full schema reference, see Schema & Models. For migration workflows, see Migrations & Seeding.

Architecture

The database layer connects your Next.js application to PostgreSQL through three layers:
Next.js Application
    |
    |--- Server Components ──> Repository / Query Layer ──> Prisma Client
    |--- API Routes ─────────> Repository / Query Layer ──> Prisma Client
    |--- Server Actions ─────> Repository / Query Layer ──> Prisma Client
    |
    v
Prisma Client (Singleton)
    |
    |--- Queries ──> Pooled Connection (pgbouncer) ──> Supabase PostgreSQL
    |--- Migrations ──> Direct Connection ───────────> Supabase PostgreSQL
    |
    v
PostgreSQL (Supabase)
    |--- pgvector extension (semantic search)
    |--- 10 tables (users, subscriptions, AI, credits, files, email)
Why both Supabase and Prisma? Each handles what it does best:
  • Prisma provides the type-safe query API, schema management, and migration tooling. Every database query in your application code goes through Prisma, giving you full TypeScript autocompletion and compile-time safety.
  • Supabase provides the hosted PostgreSQL instance, connection pooling via pgbouncer, the dashboard for database inspection, and PostgreSQL extensions like pgvector. You never interact with the Supabase JS client for data queries — Prisma handles all of that.

Connection Configuration

Supabase requires two connection URLs — one pooled (for application queries) and one direct (for schema migrations). This is configured in the Prisma schema:
prisma/schema.prisma
generator client {
  provider        = "prisma-client-js"
  binaryTargets   = ["native", "rhel-openssl-3.0.x"]
  previewFeatures = ["postgresqlExtensions"]
}

datasource db {
  provider   = "postgresql"
  // Use pooled connection for queries (with pgbouncer)
  url        = env("DATABASE_URL")
  // Use direct connection for migrations (without pgbouncer)
  directUrl  = env("DIRECT_URL")
  extensions = [vector]
}
The corresponding environment variables in apps/boilerplate/.env.local:
bash
# Pooled connection (for queries) — port 6543 on Supabase
DATABASE_URL="postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?pgbouncer=true"

# Direct connection (for migrations) — port 5432 on Supabase
DIRECT_URL="postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgres"
Get both URLs from your Supabase project dashboard under Settings > Database > Connection string. Select "URI" format and copy the pooled (port 6543) and direct (port 5432) variants.

Prisma Client

The Prisma client is initialized as a singleton to prevent connection exhaustion during Next.js hot reloads in development. It also handles test and demo environments automatically:
src/lib/db/prisma.ts — Singleton Pattern
const prismaClientSingleton = () => {
  // Check for test/demo environment
  // Demo mode uses mock data, no real database needed
  const isDemoMode = process.env.NEXT_PUBLIC_DEMO_MODE === 'true'
  const isClerkDisabled = process.env.NEXT_PUBLIC_CLERK_ENABLED === 'false'
  const isTestEnvironment = isDemoMode || isClerkDisabled

  // In test/demo environment, use mock client to avoid database connection
  if (isTestEnvironment) {
    console.log('[TEST/DEMO] Using mock Prisma client - no database connection')
    return createMockPrismaClient()
  }

  return new PrismaClient({
    // Optimize for serverless/edge environments
    log:
      process.env.NODE_ENV === 'development'
        ? ['query', 'error', 'warn']
        : ['error'],
    // Error formatting
    errorFormat: process.env.NODE_ENV === 'development' ? 'pretty' : 'minimal',
  })
}

// Prevent multiple instances during development hot reload
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton()

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}
Three things to note:
  1. Hot-reload protection — In development, the client is stored on globalThis so that hot module replacement doesn't create new database connections on every file change.
  2. Test/demo mode — When NEXT_PUBLIC_DEMO_MODE=true or NEXT_PUBLIC_CLERK_ENABLED=false, a mock Proxy client is returned instead. This allows the full application to run without a database connection during E2E tests and demos.
  3. Graceful shutdown — In production, the client disconnects cleanly on process exit to prevent connection leaks.
Import the client anywhere in your server-side code:
typescript
import { prisma } from '@/lib/db/prisma'

// Use in Server Components, API routes, or Server Actions
const user = await prisma.user.findUnique({
  where: { clerkId: userId }
})

Data Access Patterns

Kit organizes database access into three layers, each serving a different purpose:

1. Repository Pattern

The repository layer provides an abstraction over Prisma with automatic test mode detection. Each repository extends RepositoryBase, which returns mock data in test environments and real Prisma queries in production:
src/lib/db/repository-base.ts
export abstract class RepositoryBase<T> {
  /**
   * Detects if application is running in test mode
   *
   * Test mode is detected by:
   * - NODE_ENV === 'test' (but overridden by DISABLE_REPOSITORY_MOCKS)
   * - NEXT_PUBLIC_CLERK_ENABLED === 'false' (E2E test environment)
   *
   * CRITICAL: Unit tests set DISABLE_REPOSITORY_MOCKS=true to use Prisma mocks.
   * This allows unit tests to have full control over database behavior while
   * E2E tests use Repository mock data (no database required).
   *
   * @returns true if in test mode, false for production/development/unit-tests
   */
  protected isTestMode(): boolean {
    // CRITICAL: Allow unit tests to disable repository mocking
    // Unit tests mock Prisma directly and need full control over data flow
    if (process.env.DISABLE_REPOSITORY_MOCKS === 'true') {
      return false
    }

    // E2E tests and other test scenarios use repository mocks
    return (
      process.env.NODE_ENV === 'test' ||
      process.env.NEXT_PUBLIC_CLERK_ENABLED === 'false'
    )
  }

  /**
   * Returns mock data for test environment
   *
   * Each repository must implement this method to provide
   * realistic test data that matches production schema.
   *
   * @returns Mock data instance
   */
  protected abstract getMockData(): T

  /**
   * Optional: Get mock data with custom properties
   *
   * Allows repositories to provide flexible mock data generation
   *
   * @param overrides - Partial properties to override defaults
   * @returns Mock data with overrides applied
   */
  protected getMockDataWithOverrides(overrides?: Partial<T>): T {
    return {
      ...this.getMockData(),
      ...overrides,
    } as T
  }
}
Use repositories for read operations that need to work seamlessly across production and test environments. The userRepository is the primary example:
typescript
import { userRepository } from '@/lib/db/repositories'

// Works in production (real queries) and tests (mock data) — no code changes needed
const user = await userRepository.findByClerkId(clerkId)
const userWithSub = await userRepository.findByClerkIdWithSubscription(clerkId)

2. Query Modules

Query modules are thin, focused functions that wrap Prisma operations for specific models. They provide a clean API without the test-mode abstraction:
src/lib/db/queries/users.ts
import { prisma } from '@/lib/db/prisma'
import { userRepository } from '@/lib/db/repositories'
import type { User } from '@prisma/client'

/**
 * Get user by Clerk ID
 */
export async function getUserByClerkId(clerkId: string): Promise<User | null> {
  return userRepository.findByClerkId(clerkId)
}

/**
 * Get user by ID
 */
export async function getUserById(id: string): Promise<User | null> {
  return userRepository.findById(id)
}

/**
 * Create user from Clerk data
 */
export async function createUser(clerkId: string): Promise<User> {
  return prisma.user.create({
    data: {
      clerkId,
    },
  })
}

/**
 * Update user
 */
export async function updateUser(
  id: string,
  data: Partial<User>
): Promise<User> {
  return prisma.user.update({
    where: { id },
    data,
  })
}

/**
 * Delete user
 */
export async function deleteUser(id: string): Promise<User> {
  return prisma.user.delete({
    where: { id },
  })
}

/**
 * Get user bonus credit preference
 */
export async function getUserBonusPreference(
  userId: string
): Promise<{ bonusCreditsAutoUse: boolean } | null> {
  return prisma.user.findUnique({
    where: { id: userId },
    select: { bonusCreditsAutoUse: true },
  })
}

/**
 * Update user bonus credit preference
 */
export async function updateUserBonusPreference(
  userId: string,
  bonusCreditsAutoUse: boolean
): Promise<{ bonusCreditsAutoUse: boolean }> {
  return prisma.user.update({
    where: { id: userId },
    data: { bonusCreditsAutoUse },
    select: { bonusCreditsAutoUse: true },
  })
}
The apps/boilerplate/src/lib/db/queries/ directory contains modules for each domain: users.ts, subscriptions.ts, files.ts, and email-logs.ts. These are the functions you call from API routes and Server Actions.

3. Direct Prisma Access

For complex operations that need transactions, raw SQL, or operations spanning multiple models, use the Prisma client directly. This is common in services and managers:
typescript
import { prisma } from '@/lib/db/prisma'

// Atomic credit deduction with row-level locking
const result = await prisma.$transaction(async (tx) => {
  const user = await tx.$queryRaw`
    SELECT "creditBalance" FROM "User"
    WHERE "id" = ${userId} FOR UPDATE
  `
  await tx.user.update({
    where: { id: userId },
    data: { creditBalance: { decrement: cost } }
  })
  await tx.creditTransaction.create({
    data: { userId, amount: -cost, type: 'usage' }
  })
})

Server Component Best Practice

In Server Components, always query the database directly instead of calling your own API routes:
typescript
// src/app/(dashboard)/layout.tsx — Server Component

// CORRECT: Direct database query (< 100ms)
const user = await userRepository.findByClerkId(clerkId)

// WRONG: Self-referencing fetch — server blocks waiting on itself (7+ seconds!)
const res = await fetch('http://localhost:3000/api/users/me')
Kit follows this pattern throughout the dashboard layout and all server-rendered pages.

Model Overview

Kit ships with 10 Prisma models that cover the core SaaS features. Here is a summary — for full field details, see Schema & Models.
ModelPurposeKey Relations
UserCore user entity with Clerk sync, dual pricing support (credits + classic SaaS)Has many: Subscription, File, EmailLog, AIUsage, AIConversation, CreditTransaction, BonusCreditPurchase
SubscriptionLemon Squeezy subscription tracking with plan historyBelongs to User (cascade delete)
CreditTransactionImmutable credit ledger (usage, refunds, resets, purchases)Belongs to User
BonusCreditPurchaseTop-up credit purchases via Lemon SqueezyBelongs to User
AIUsagePer-request AI token and cost trackingBelongs to User
AIConversationChat conversation threads with session trackingBelongs to User, has many AIMessage
AIMessageIndividual chat messages (user, assistant, system)Belongs to AIConversation
FAQChunkRAG knowledge base with pgvector embeddings (1536 dimensions)Standalone
FileVercel Blob file upload metadataBelongs to User
EmailLogResend email delivery tracking with analyticsBelongs to User

Environment Variables

VariableRequiredPurpose
DATABASE_URLYesPooled PostgreSQL connection (pgbouncer) for application queries
DIRECT_URLYesDirect PostgreSQL connection for Prisma migrations
NEXT_PUBLIC_SUPABASE_URLYesSupabase project URL (for storage and other Supabase features)
NEXT_PUBLIC_SUPABASE_ANON_KEYYesSupabase anonymous key (public, safe for client-side)
SUPABASE_SERVICE_ROLE_KEYNoSupabase service role key (server-only, bypasses RLS)

Key Files

FilePurpose
apps/boilerplate/prisma/schema.prismaDatabase schema — all models, relations, indexes, and config
apps/boilerplate/src/lib/db/prisma.tsPrisma client singleton with test mode and graceful shutdown
apps/boilerplate/src/lib/db/client.tsBackwards-compatible re-export of the Prisma client
apps/boilerplate/src/lib/db/supabase.tsSupabase client for storage and auth features
apps/boilerplate/src/lib/db/repository-base.tsAbstract repository base class with test mode detection
apps/boilerplate/src/lib/db/repositories/user-repository.tsUser data access with performance logging
apps/boilerplate/src/lib/db/queries/users.tsUser CRUD operations
apps/boilerplate/src/lib/db/queries/subscriptions.tsSubscription queries with business logic
apps/boilerplate/src/lib/db/queries/files.tsFile metadata CRUD with pagination and search
apps/boilerplate/src/lib/db/queries/email-logs.tsEmail tracking, analytics, and GDPR cleanup
apps/boilerplate/prisma/setup-pgvector.sqlSQL script to enable pgvector extension and create indexes