← All articles

Supabase and Prisma ORM: setup, DB-first mapping and safe queries

Separate Prisma 6 and 7 setup, then configure the pg adapter, TLS, introspection, generated queries and transaction boundaries.

t.Turgay
Supabase and Prisma ORM: setup, DB-first mapping and safe queries

Prisma and Supabase have different roles. Supabase hosts PostgreSQL and, separately, file Storage; Prisma defines how a Node/TypeScript server queries PostgreSQL. Combining them does not require exposing database access to the browser or recreating an existing schema.

My older NextPortfolio source uses the Prisma 6.15 family, prisma-client-js, datasource url/directUrl settings and a development globalThis client cache. This guide adds a separate Prisma 7.10.1 example rather than mixing those setup generations. The current PortfolioBlog API continues to use Npgsql/EF Core; the example demonstrates the TypeScript alternative without replacing that backend.

Install matching packages in a separate project

npm init -y
npm pkg set type=module
npm install @prisma/client@7.10.1 @prisma/adapter-pg@7.10.1 pg dotenv
npm install --save-dev prisma@7.10.1 typescript tsx @types/node @types/pg

The example runs on Node 24 and pins the Prisma CLI, Client and pg adapter to 7.10.1. pg supplies the database driver, the adapter connects it to Prisma, and the generated Client supplies the query API. dotenv loads environment files and tsx runs the TypeScript entry point. Upgrading one of these packages to another major version independently is not the setup demonstrated here.

The code blocks belong in a standalone Prisma project created by following this article’s setup steps. Dependencies and generated client files remain outside Git, while the lockfile records the installation. These packages are not added to the main Angular or API runtime. A tool introduced for an article should not silently become a production dependency.

Separate application traffic from CLI configuration

# Runtime: verified TLS is configured explicitly in the pg adapter.
DATABASE_URL="postgresql://postgres.YOUR_PROJECT_REF:YOUR_PASSWORD@YOUR_POOLER_HOST:5432/postgres"
PG_CA_PATH="/absolute/path/to/provider-ca.crt"
# CLI: its own connection and certificate options, not the pg adapter settings.
DIRECT_URL="postgresql://postgres.YOUR_PROJECT_REF:YOUR_PASSWORD@YOUR_POOLER_HOST:5432/postgres?sslmode=require&sslcert=YOUR_URL_ENCODED_ABSOLUTE_CA_PATH&sslaccept=strict"

DATABASE_URL serves runtime queries; DIRECT_URL serves the CLI/introspection path. Both use the IPv4-compatible Session pooler/5432 here. The variable’s name does not require using an IPv6 direct endpoint. Copy the host and postgres.PROJECT_REF username from the provider panel, and encode reserved characters in passwords and certificate URI parameters. The displayed values are placeholders.

Runtime TLS is configured explicitly in the pg adapter. The example rejects sslmode/sslcert/sslkey/sslrootcert parameters in DATABASE_URL because node-postgres can replace the explicit ssl object when URI SSL options are present. The CLI does not run that adapter, so its separate DIRECT_URL configures sslmode=require, the provider’s public CA through sslcert, and sslaccept=strict. Verify both connection paths rather than assuming they share configuration.

import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';

export default defineConfig({
  schema: 'prisma/schema.prisma',
  datasource: { url: env('DIRECT_URL') },
});

In Prisma 7, prisma.config.ts supplies the CLI datasource URL. The schema describes data and the config describes the CLI connection. env("DIRECT_URL") makes a missing setting visible early. This config also loads for validate/generate; a syntactically valid development placeholder can suffice for local generation, whereas introspection needs a real authorized connection.

Map existing names without renaming the database

generator client {
  provider = "prisma-client"
  output   = "../generated"
}

datasource db {
  provider = "postgresql"
}

// Read projection of an existing table, not the table's complete write model.
// Do not run Prisma Migrate or db push against the PortfolioBlog schema.
model Post {
  id              String   @id @db.Uuid
  slug            String   @unique @db.VarChar(150)
  titleTr         String   @map("title_tr") @db.VarChar(200)
  published       Boolean
  deleted         Boolean
  sourceCreatedAt DateTime @map("source_created_at") @db.Timestamptz(6)

  @@map("post")
}

@@map connects Post to the existing post table; @map connects titleTr to title_tr. TypeScript naming can therefore be readable without renaming SQL columns. sourceCreatedAt matches the real timestamptz(6) column. Declaring String does not imply that every corresponding PostgreSQL column has identical length, native type or constraints.

This model intentionally lists only the fields required for a read projection. It omits category_id, site_id, bilingual bodies, position and other required columns of the real table. It is not a complete create model. Applying migrations or db push from this partial schema could misrepresent existing columns and relationships, so the application’s SQL-managed schema remains authoritative.

Distinguish introspection, generation and migrations

npx prisma db pull --print
npx prisma validate
npx prisma generate
npx tsc --noEmit

db pull --print reads the database and prints the model without changing this example’s schema file. Without --print, db pull updates the local schema.prisma file, not the tables. generate produces a TypeScript client from the model and does not apply database migrations. In a SQL-first workflow, review the SQL change, inspect the updated model and regenerate the client.

A new, separate application may instead assign migration ownership to Prisma. Development migration creation and deployment of reviewed migrations then become part of its own workflow. This read-projection example does not run migrate reset, db push or migrate dev against PortfolioBlog. Adopting an ORM for queries does not automatically transfer ownership of the entire schema.

Configure the pg adapter, pool and certificate

import 'dotenv/config';
import fs from 'node:fs';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/client.ts';

const connectionString = process.env.DATABASE_URL;
const certificatePath = process.env.PG_CA_PATH;
if (!connectionString || !certificatePath) throw new Error('Set DATABASE_URL and PG_CA_PATH.');
const url = new URL(connectionString);
// pg's URI sslmode/sslrootcert options override the explicit ssl object.
if (['sslmode', 'sslcert', 'sslkey', 'sslrootcert'].some((key) => url.searchParams.has(key)))
  throw new Error('Remove URI SSL options; verified TLS is configured by the adapter.');
if (url.port === '6543') throw new Error('This example uses Session pooler port 5432.');

export function createDatabase() {
  const adapter = new PrismaPg({
    connectionString,
    max: 5,
    connectionTimeoutMillis: 15000,
    ssl: { rejectUnauthorized: true, ca: fs.readFileSync(certificatePath!, 'utf8') },
  });
  return new PrismaClient({ adapter });
}

createDatabase is a factory. A persistent server should call it for the appropriate process lifetime, not once per request. max=5 limits this pg pool, and connectionTimeoutMillis bounds connection acquisition. The certificate is read from disk while rejectUnauthorized stays enabled. Session mode is an explicit example constraint; a serverless deployment needs its own pooling decision.

Read a bounded projection in a stable order

import { createDatabase } from './db.ts';

const prisma = createDatabase();
try {
  const posts = await prisma.post.findMany({
    where: { published: true, deleted: false },
    select: { slug: true, titleTr: true, sourceCreatedAt: true },
    orderBy: [{ sourceCreatedAt: 'desc' }, { id: 'asc' }],
    take: 5,
  });
  console.log(JSON.stringify(posts, null, 2));
} finally {
  await prisma.$disconnect();
}

where applies publication and deletion filtering in SQL. select fetches only the listed fields. The id tie-breaker stabilizes equal timestamps, and take=5 bounds the result. Generated query methods provide parameterized access; raw SQL still requires safe parameter binding rather than concatenated user input. TypeScript types do not validate an untrusted HTTP request at runtime.

This entry point is a one-shot CLI, so finally calls $disconnect. A web endpoint should share an appropriately scoped process client instead of shutting down its pool after every request. The following server-only development cache follows the pattern present in NextPortfolio and avoids creating a new client on each hot reload.

// Server-only module for a persistent development server.
// createDatabase is the factory shown above; never import this into a client bundle.
import { createDatabase } from './db';

type Client = ReturnType<typeof createDatabase>;
const shared = globalThis as typeof globalThis & { portfolioPrisma?: Client };
export const prisma = shared.portfolioPrisma ?? createDatabase();
if (process.env.NODE_ENV !== 'production') shared.portfolioPrisma = prisma;

An update method does not implement authorization or revision control

// Server-side extension for an authorized administrative operation.
// It is NOT executed by the read-only example.
const changed = await prisma.post.updateMany({
  where: { id: authorizedPostId, deleted: false },
  data: { published: false }
});
if (changed.count !== 1) throw new Error('Record missing or state changed.');

This extension illustrates an already authorized administrator action and is not executed by the read command. A client-provided record id does not authorize access. Checking count avoids silently reporting success for a missing or changed record. The snippet does not reproduce PortfolioBlog’s revision increment and Serializable save transaction.

Multiple dependent changes need an explicit $transaction boundary, isolation choice and conflict behavior. Placing a Storage HTTP call inside a database callback does not make the two systems atomic. A Prisma client using an owning/privileged SQL role also does not automatically derive an end-user identity for Supabase RLS. Publication filters, API authorization and database grants remain deliberate controls.

What the example verifies

  • The Prisma 7.10.1 schema validated, its client generated, and tsc --noEmit passed.
  • The example read five Supabase records through verified TLS. Read-only db pull --print also succeeded with its CA/strict acceptance settings.
  • These checks do not establish migration-write safety, measured performance or a completed upgrade of the entire older NextPortfolio application.
  • ORM queries need an SQL connection, not a Storage SDK or service_role API key. Keep the client and database password on the server.

The useful setup boundary is knowing which schema and connection produced the client. A more convenient query API does not choose publication rules, authorization or transaction semantics on my behalf.

References

✳ You have reached the end of this orbit.
Explore more articles →
turgay.A little code, a few pixels, a lot of curiosity.© 2026 · Made on Earth ✳