Skip to main content
Prisma is the most-used TypeScript ORM. It pairs well with Powabase: schema lives in schema.prisma, the generated client is fully typed, and Prisma Migrate handles schema evolution. The only Powabase-specific setup is the connection URL flags PgBouncer transaction-mode pooling needs. For the broader pooler context, see Connection pooling. For migration mechanics, see Migrations.

Connection setup

Copy the Database URL from the Connect modal, then add two query params:
DATABASE_URL="postgresql://<ref>:<password>@db.p.powabase.ai:5432/<ref>?pgbouncer=true&connection_limit=1"
  • pgbouncer=true — disables Prisma’s prepared-statement cache. PgBouncer in transaction mode breaks prepared statements; this flag flips Prisma to the simple query protocol.
  • connection_limit=1 — prevents Prisma from opening more connections than your share of the project’s pool (20 by default). Without it, Prisma may try to open many connections concurrently and saturate the pooler.
Put both in .env. Don’t commit the URL.

Minimal schema

prisma/schema.prisma:
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(uuid()) @db.Uuid
  email     String   @unique
  createdAt DateTime @default(now()) @map("created_at")
  posts     Post[]

  @@map("users")
}

model Post {
  id        String   @id @default(uuid()) @db.Uuid
  authorId  String   @map("author_id") @db.Uuid
  title     String
  body      String
  published Boolean  @default(false)
  createdAt DateTime @default(now()) @map("created_at")
  author    User     @relation(fields: [authorId], references: [id])

  @@map("posts")
}
The @@map and @map lines map TypeScript camelCase to SQL snake_case. Use them — PostgREST, your migrations, and any future raw SQL will all use the snake_case names.

Queries

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

// Insert
const alice = await prisma.user.create({
  data: { email: "alice@example.com" },
});

// Read with relation
const posts = await prisma.post.findMany({
  where: { authorId: alice.id, published: true },
  include: { author: { select: { email: true } } },
  orderBy: { createdAt: "desc" },
  take: 10,
});

// Update
await prisma.user.update({
  where: { id: alice.id },
  data: { email: "alice@new.example.com" },
});

// Delete (cascades by foreign-key default; configure with onDelete in schema)
await prisma.user.delete({ where: { id: alice.id } });

// Transaction
await prisma.$transaction([
  prisma.user.create({ data: { email: "bob@example.com" } }),
  prisma.post.create({ data: { authorId: alice.id, title: "hi", body: "world" } }),
]);

Migrations

# Development — generates AND applies a migration
npx prisma migrate dev --name add_users

# Production — only applies pending migrations
npx prisma migrate deploy
migrate dev doesn’t play well with the pooler — it tries to open a shadow database, and connection_limit=1 can cause hangs. The pattern most teams use:
  1. Run migrate dev against a local Postgres during development (point DATABASE_URL at a local container, not Powabase).
  2. Commit the generated SQL migration files to your repo.
  3. In CI/CD against Powabase, only run migrate deploy.
Prisma’s _prisma_migrations tracking table handles the rest. See Migrations for failure recovery.

RLS and Prisma

Prisma connects as supabase_admin (full schema ownership, bypasses RLS). The client cannot impersonate other roles; if your app needs RLS-respecting queries from your backend, your options are:
  1. Use the PostgREST API (/rest/v1/*) with the user’s access token. PostgREST sets the role from the JWT. Don’t use Prisma for that subset of queries.
  2. Set the role manually in a transaction:
await prisma.$transaction(async (tx) => {
  await tx.$executeRaw`SET LOCAL ROLE authenticated`;
  await tx.$executeRaw`SET LOCAL request.jwt.claims = ${JSON.stringify({
    sub: userId,
    role: "authenticated",
  })}::jsonb`;
  return tx.user.findMany();
});
Inside the transaction, RLS applies as it would for any other authenticated request. Both SET LOCAL statements must be inside the same transaction as the queries — PgBouncer transaction mode is what makes this safe. For most backend code, option 1 (direct PostgREST for RLS-required reads, Prisma for everything else) is cleaner than option 2.

Serverless gotchas

Lambda, Vercel Functions, and Cloudflare Workers tear down state between invocations. Prisma’s default PrismaClient instance holds connections open across invocations expecting a long-running process; in serverless, each cold-start opens new connections and leaks them. Two paths:
  1. Use Prisma’s Data Proxy / Accelerate. Prisma routes queries through a hosted connection pool. Compatible with serverless. Costs money.
  2. Open and close per invocation. Create new PrismaClient() at the top of the handler, await prisma.$disconnect() before returning. Burns the cold-start time on every invocation, but doesn’t leak connections.
For high-traffic serverless apps, neither option is great. Consider whether the Postgres-direct shape is the right one — for read-heavy workloads, PostgREST through Powabase’s existing scale is often the simpler answer.

Next steps

Connection pooling

Why the Prisma flags above are needed.

Migrations

The migration patterns across all three ORMs we cover.

Direct Postgres

For SQL Prisma doesn’t express — bulk imports, schema introspection, etc.

Drizzle

A lighter-touch TypeScript alternative.