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]
}
Supabase routes pooled connections through pgbouncer in transaction mode, which strips session-level features like
SET statements and advisory locks. Prisma migrations rely on these features, so they need a direct connection that bypasses the pooler. In development with a local database, both URLs can point to the same connection.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:
- Hot-reload protection — In development, the client is stored on
globalThisso that hot module replacement doesn't create new database connections on every file change. - Test/demo mode — When
NEXT_PUBLIC_DEMO_MODE=trueorNEXT_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. - 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' }
})
})
Which layer should you use? Start with query modules for standard CRUD. Use repositories when you need test-mode compatibility for reads. Use direct Prisma access for transactions, raw SQL, or cross-model operations that don't fit a single query function.
Server Component Best Practice
Using
fetch() to call your own API routes from Server Components causes catastrophic performance degradation — page loads jump from ~400ms to 7+ seconds. The Next.js dev server has limited request concurrency, and a Server Component making an HTTP request to the same server causes it to block waiting on its own response. Multiple prefetch calls compound the problem exponentially.Measured impact: Dashboard page load dropped from 7,429ms to 445ms (94% improvement) by switching from
fetch('/api/subscription') to direct getSubscriptionByUserId() calls.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.
| Model | Purpose | Key Relations |
|---|---|---|
| User | Core user entity with Clerk sync, dual pricing support (credits + classic SaaS) | Has many: Subscription, File, EmailLog, AIUsage, AIConversation, CreditTransaction, BonusCreditPurchase |
| Subscription | Lemon Squeezy subscription tracking with plan history | Belongs to User (cascade delete) |
| CreditTransaction | Immutable credit ledger (usage, refunds, resets, purchases) | Belongs to User |
| BonusCreditPurchase | Top-up credit purchases via Lemon Squeezy | Belongs to User |
| AIUsage | Per-request AI token and cost tracking | Belongs to User |
| AIConversation | Chat conversation threads with session tracking | Belongs to User, has many AIMessage |
| AIMessage | Individual chat messages (user, assistant, system) | Belongs to AIConversation |
| FAQChunk | RAG knowledge base with pgvector embeddings (1536 dimensions) | Standalone |
| File | Vercel Blob file upload metadata | Belongs to User |
| EmailLog | Resend email delivery tracking with analytics | Belongs to User |
Environment Variables
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | Yes | Pooled PostgreSQL connection (pgbouncer) for application queries |
DIRECT_URL | Yes | Direct PostgreSQL connection for Prisma migrations |
NEXT_PUBLIC_SUPABASE_URL | Yes | Supabase project URL (for storage and other Supabase features) |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Yes | Supabase anonymous key (public, safe for client-side) |
SUPABASE_SERVICE_ROLE_KEY | No | Supabase service role key (server-only, bypasses RLS) |
Key Files
| File | Purpose |
|---|---|
apps/boilerplate/prisma/schema.prisma | Database schema — all models, relations, indexes, and config |
apps/boilerplate/src/lib/db/prisma.ts | Prisma client singleton with test mode and graceful shutdown |
apps/boilerplate/src/lib/db/client.ts | Backwards-compatible re-export of the Prisma client |
apps/boilerplate/src/lib/db/supabase.ts | Supabase client for storage and auth features |
apps/boilerplate/src/lib/db/repository-base.ts | Abstract repository base class with test mode detection |
apps/boilerplate/src/lib/db/repositories/user-repository.ts | User data access with performance logging |
apps/boilerplate/src/lib/db/queries/users.ts | User CRUD operations |
apps/boilerplate/src/lib/db/queries/subscriptions.ts | Subscription queries with business logic |
apps/boilerplate/src/lib/db/queries/files.ts | File metadata CRUD with pagination and search |
apps/boilerplate/src/lib/db/queries/email-logs.ts | Email tracking, analytics, and GDPR cleanup |
apps/boilerplate/prisma/setup-pgvector.sql | SQL script to enable pgvector extension and create indexes |