This document explains the database setup using Next.js 15, DrizzleORM, and Supabase.
src/db/
├── index.ts # Database connection and configuration
├── migrate.ts # Migration runner script
├── seed.ts # Database seeding script
├── utils.ts # Database utility functions
├── migrations/ # Generated SQL migrations
├── schemas/ # Drizzle schema definitions
│ ├── index.ts
│ ├── user.ts
│ ├── team.ts
│ └── player.ts
├── queries/ # Reusable database queries
│ ├── teams.ts
│ └── players.ts
└── seeds/ # Seed data and scripts
Required environment variables (see .env.example):
# Supabase Database URL (use Transaction pooler for production)
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
# Supabase API Configuration
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_keyimport { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "@/db/schemas";
// Optimized for Supabase Transaction pooling mode
const connection = postgres(DATABASE_URL, {
prepare: false, // Required for Supabase
max: 10, // Connection pool size
idle_timeout: 20, // Idle timeout in seconds
max_lifetime: 60 * 30, // Max connection lifetime (30 min)
});
export const db = drizzle(connection, { schema });import { timestamp } from "drizzle-orm/pg-core";
// ✅ GOOD - Returns Date objects
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true
}).notNull().defaultNow(),
// ✅ GOOD - Auto-updates on record update
updatedAt: timestamp("updated_at", {
mode: "date",
withTimezone: true
}).notNull().defaultNow().$onUpdate(() => new Date()),
// ❌ BAD - Returns strings, harder to work with
createdAt: timestamp("created_at", { mode: "string" }).notNull().defaultNow(),import { relations } from "drizzle-orm";
export const teamRelations = relations(team, ({ many }) => ({
players: many(player),
}));
export const playerRelations = relations(player, ({ one }) => ({
team: one(team, {
fields: [player.teamId],
references: [team.id],
}),
}));export type UserRow = typeof user.$inferSelect;
export type NewUserRow = typeof user.$inferInsert;import { cache } from "react";
// ✅ Cached queries are deduplicated within the same request
export const getTeamById = cache(async (id: string) => {
return await db.query.team.findFirst({
where: eq(team.id, id),
with: { players: true },
});
});import { calculatePagination, type PaginationOptions } from "@/db/utils";
export async function getTeams(options?: PaginationOptions) {
const total = await getTeamsCount();
const { offset, limit, pagination } = calculatePagination(
total,
options?.page,
options?.pageSize
);
const data = await db.query.team.findMany({
limit,
offset,
orderBy: [desc(team.createdAt)],
});
return { data, pagination };
}import { ilike, or } from "drizzle-orm";
export async function searchTeams(query: string) {
const searchTerm = `%${query}%`;
return await db.query.team.findMany({
where: or(
ilike(team.name, searchTerm),
ilike(team.country, searchTerm),
ilike(team.league, searchTerm)
),
});
}pnpm db:generateThis creates a SQL migration file in src/db/migrations/ based on schema changes.
Always review the generated SQL before applying:
-- Example: src/db/migrations/0001_add_player_stats.sql
CREATE TABLE IF NOT EXISTS "player_stats" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"player_id" uuid NOT NULL REFERENCES "players"("id"),
"goals" integer DEFAULT 0,
"assists" integer DEFAULT 0
);# Local development
pnpm db:migrate
# Production (use Supabase Dashboard or CI/CD)pnpm db:seedUse the utility functions from src/db/utils.ts:
import {
isUniqueConstraintError,
getDatabaseErrorMessage
} from "@/db/utils";
try {
await db.insert(team).values(newTeam);
} catch (error) {
if (isUniqueConstraintError(error)) {
return { error: "Team name already exists" };
}
return { error: getDatabaseErrorMessage(error) };
}// ❌ BAD - Client component importing server-only queries
"use client";
import { getTeams } from "@/db/queries/teams"; // ERROR!
// ✅ GOOD - Use Server Actions or Server Components
// In Server Component:
export default async function TeamsPage() {
const teams = await getTeams();
return <TeamList teams={teams} />;
}
// Or in Server Action:
"use server";
import { getTeams } from "@/db/queries/teams";
export async function getTeamsAction() {
return await getTeams();
}While DrizzleORM handles application-level queries, always enable RLS in Supabase for defense-in-depth:
-- Enable RLS on tables
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
-- Example policy
CREATE POLICY "Users can view all teams"
ON teams FOR SELECT
TO authenticated
USING (true);"use server";
import { z } from "zod";
const TeamSchema = z.object({
name: z.string().min(1).max(100),
country: z.string().min(2).max(100),
});
export async function createTeam(formData: FormData) {
const data = TeamSchema.parse({
name: formData.get("name"),
country: formData.get("country"),
});
return await db.insert(team).values(data);
}Always use Supabase's Transaction Mode Pooler for production:
postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
// ✅ GOOD - Select only needed columns
const teams = await db
.select({ id: team.id, name: team.name })
.from(team);
// ❌ BAD - Returns all columns when you only need a few
const teams = await db.query.team.findMany();import { index } from "drizzle-orm/pg-core";
const player = pgTable("players", {
// ... fields
}, (table) => ({
teamIdIdx: index("player_team_id_idx").on(table.teamId),
emailIdx: index("player_email_idx").on(table.email),
}));// ✅ GOOD - Single batch insert
await db.insert(team).values([team1, team2, team3]);
// ❌ BAD - Multiple individual inserts
for (const t of teams) {
await db.insert(team).values(t);
}- DrizzleORM Documentation
- Supabase Connection Pooling
- Next.js Data Fetching
- Postgres.js Documentation
Solution: Ensure prepare: false in connection config (already configured).
Solution:
- Use Supabase Transaction pooler URL
- Reduce
maxconnections in config - Ensure connections are properly closed in migration/seed scripts
Solution:
- Run
pnpm db:generateto generate migrations - Run
pnpm db:migrateto apply migrations - Check if you're connected to the correct database
Last Updated: October 2025