prisma-next-supabase
Use Prisma Next with a Supabase project via `@prisma-next/extension-supabase` — wire `extensionPacks: [supabasePack]`, declare cross-space FKs to `supabase:auth.AuthUser`, author RLS policies (`policy_select` / `policy_update` / `@@rls`, `auth.uid()` predicates), build `db.ts` with the `supabase()` factory, bind roles per request (`asUser(jwt)` / `asAnon()` / `asServiceRole()`), query `auth.*` / `storage.*` via the `db.supabase` admin root, and validate JWTs (`jwksUrl` for current projects / `jwtSecret` for legacy HS256). Use for supabase, RLS, row level security, policy, role binding, anon, authenticated, service_role, auth.users, auth.uid(), JWT, JWKS, SUPABASE_JWKS_URL, SUPABASE_JWT_SECRET, InvalidJwtError, SupabaseConfigError, RoleBoundDb, session pooler, supabase:auth.AuthUser, @prisma-next/extension-supabase.
How do I install this agent skill?
npx skills add https://github.com/prisma/prisma-next --skill prisma-next-supabaseIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides comprehensive instructions for integrating Prisma Next with Supabase, focusing on row-level security (RLS), role-bound queries, and contract management. It follows best practices for secret management and identifies vendor-specific packages correctly. No malicious patterns were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Prisma Next — Supabase
Edit your data contract. Prisma handles the rest.
This skill covers using Prisma Next against a Supabase project end-to-end: composing the Supabase extension pack, referencing Supabase-owned tables from your contract, authoring row-level-security (RLS) policies, and running role-bound queries through the supabase() runtime.
When to Use
- User has a Supabase project (or wants one) and is wiring Prisma Next into it.
- User wants RLS policies on their tables (
policy_select,@@rls,auth.uid()). - User wants per-request role binding (
asUser(jwt),asAnon(),asServiceRole()). - User wants a foreign key into
auth.users(cross-space FK). - User wants to read Supabase-internal tables (
auth.*,storage.*) as an admin. - User mentions: supabase, RLS, row level security, policy, anon, authenticated, service_role, auth.users, auth.uid(), JWT, jwtSecret, jwksUrl, InvalidJwtError, RoleBoundDb, session pooler.
When Not to Use
- General contract editing (models, fields, relations) →
prisma-next-contract. - Non-Supabase
db.tswiring, middleware, teardown →prisma-next-runtime. - General query shapes (filtering, includes, aggregates) →
prisma-next-queries— everything there applies to a role-bounddbtoo. - Migration planning / applying →
prisma-next-migrations.
Key Concepts
- The pack is an
externalcontract space.@prisma-next/extension-supabase/packships a complete, introspection-generated contract of everything Supabase owns — theauthandstorageschemas, their native enum types, and the platform roles (anon,authenticated,service_role) — all with control policyexternal. Composed viaextensionPacks, it means: the migration planner emits no DDL for those objects (Supabase manages them), anddb verifyconfirms they exist in the live database. Your own tables staymanagedas usual. - Roles come from the pack; you never declare them. RLS
roles = [authenticated]identifiers resolve against the composed contract. Pointing the runtime at a non-Supabase Postgres fails verify with anot-foundissue naming the missing role — the common "wrong database" misconfiguration surfaces before queries run. - The runtime is role-first.
supabase()returns aSupabaseDbwith no top-level query surface — there is nodb.sql/db.ormuntil you bind a role.await db.asUser(jwt)/db.asAnon()/db.asServiceRole()each return aRoleBoundDbexposing.sql,.orm,.raw,.execute(plan), and.transaction(fn). This is deliberate: in a Supabase app there is no meaningful "no role" execution context, and defaulting to the connection's login role is a silent-RLS-bypass footgun. - Role binding is below middleware and cannot leak. Each role-bound query runs on a connection that had
set_config('role', …)andset_config('request.jwt.claims', …)applied beneath the user-middleware chain, withRESET ALLon release. Postgres-sideauth.uid()/auth.jwt()read those session vars — RLS enforcement is Postgres's job; the runtime's job is binding the context. - RLS is enforced by policies and grants. Policies filter rows;
GRANTcontrols table access. Prisma Next authors and migrates the policies; it does not author grants (see What Prisma Next doesn't do yet). A role with policies but noGRANTgets a permission error, not filtered rows. On Supabase yourpublictables already carry the platform-role grants via default privileges — the grant that is actually missing out of the box isservice_role's onauth.*/storage.*(see Workflow — Grants). - JWT validation is eager and configurable — current Supabase projects need
jwksUrl.asUser(jwt)verifies the token (viajose) before any connection is acquired: signature + expiry againstjwksUrl(asymmetric signing keys — the default on current Supabase projects, which sign ES256) xorjwtSecret(the symmetric HS256 secret — legacy projects only). Both or neither →SupabaseConfigError. Bad tokens throwInvalidJwtErrorwith a typedreason— including a mismatch between the token's algorithm and the configured key source (an ES256 token against ajwtSecretclient names the problem and tells you to switch tojwksUrl). The Postgres role is derived from the token'sroleclaim (defaults toauthenticated). Note:supabase statusstill prints aJWT_SECRETeven on projects that sign ES256 — its presence does not mean your project uses it. - Admin access to
auth.*/storage.*is a secondary root onservice_roleonly — and needs a one-time grant.db.asServiceRole().supabaseexposes the pack's own contract (.sql,.orm,.nativeEnums,.execute). The root exists only onservice_roleby design, but a real Supabase project grantsservice_roleno table privileges onauth.*/storage.*(only schemaUSAGE; onlypostgresholds table grants). Before the admin root can read a Supabase-internal table, run the narrow grant once (see Workflow — Grants).asUser/asAnonhave no.supabase, and the primaryasServiceRole().sql/.ormstay scoped to your contract.
Workflow — Wire the pack into the config
The concept: the pack registers the Supabase contract space so your contract can reference it and the planner/verifier know what Supabase owns. The extension has no /control subpath yet, so it can't go through the target façade's defineConfig({ extensions: [...] }) — it wires into the low-level config's extensionPacks (see What Prisma Next doesn't do yet). The low-level imports below are a deliberate exception to the façade-only import rule, forced by that gap; the block mirrors examples/supabase/prisma-next.config.ts verbatim — copy it rather than composing your own:
// prisma-next.config.ts
import postgresAdapter from '@prisma-next/adapter-postgres/control';
import { defineConfig } from '@prisma-next/cli/config-types';
import postgresDriver from '@prisma-next/driver-postgres/control';
import supabasePack from '@prisma-next/extension-supabase/pack';
import sql from '@prisma-next/family-sql/control';
import { prismaContract } from '@prisma-next/sql-contract-psl/provider';
import postgres from '@prisma-next/target-postgres/control';
import postgresPackRef from '@prisma-next/target-postgres/pack';
import { postgresCreateNamespace } from '@prisma-next/target-postgres/types';
export default defineConfig({
family: sql,
target: postgres,
adapter: postgresAdapter,
driver: postgresDriver,
extensionPacks: [supabasePack],
contract: prismaContract('./src/contract.prisma', {
output: 'src/contract.json',
target: postgresPackRef,
createNamespace: postgresCreateNamespace,
}),
migrations: { dir: 'migrations' },
});
Workflow — Contract: FK into auth.users + RLS policies
The concept: your models live in your namespaces (public); Supabase's live in the pack's (auth, storage). A relation field typed supabase:auth.AuthUser is a cross-space FK — the planner emits REFERENCES "auth"."users"("id"), and the target table is verified, never migrated. RLS policies are top-level policy_<operation> blocks in the same namespace as their target model, and the target model must opt in with @@rls. Mirror examples/supabase/src/contract.prisma:
types {
Uuid = String @db.Uuid
}
namespace public {
model Profile {
id Uuid @id @default(uuid())
username String
userId Uuid @unique
user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profile")
@@rls
}
// authenticated may read only their own profile.
policy_select profile_owner_read {
target = Profile
roles = [authenticated]
using = "\"userId\"::uuid = auth.uid()"
}
// anon may read every profile (a public directory listing).
policy_select profile_public_read {
target = Profile
roles = [anon]
using = "true"
}
// authenticated may update only their own profile, and may not
// reassign it to another owner (WITH CHECK).
policy_update profile_owner_write {
target = Profile
roles = [authenticated]
using = "\"userId\"::uuid = auth.uid()"
withCheck = "\"userId\"::uuid = auth.uid()"
}
}
The pieces:
- Per-operation policy blocks:
policy_select,policy_insert,policy_update,policy_delete,policy_all. Body iskey = value:target(a model in this namespace),roles(resolve against the composed contract — the pack suppliesanon/authenticated/service_role),using, and (for write operations)withCheck. Multiple permissive policies per(target, operation)are valid — Postgres ORs them. @@rlsis required on policy targets. Apolicy_*block whose target model lacks@@rlsfails emit withPSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE. A model with@@rlsand no policies is also meaningful: RLS enabled, deny-all.- Predicates are verbatim SQL strings. Quote camelCase column names inside them (
\"userId\"), and cast where needed —auth.uid()returnsuuid. Renames in your contract do not rewrite predicate bodies. - TS-builder parity exists.
@prisma-next/postgres/contract-builderexportspolicySelect/policyInsert/policyUpdate/policyDelete/policyAll,rlsEnabled(Model), androle('anon')— mirroring the PSL lowering key-for-key (identical emitted wire names). PSL is the canonical path shown here.
Emit + migrate as usual (prisma-next contract emit, then prisma-next-migrations). The plan creates your table, its FK, ENABLE ROW LEVEL SECURITY, and the CREATE POLICY statements — and no DDL for auth.*.
Workflow — db.ts with the supabase() factory
The concept: instead of the stock postgres() factory, a Supabase app builds its client with supabase() from the extension's /runtime subpath. The factory is async (it prepares JWT key material — including the one-time JWKS fetch when jwksUrl is set), and the result is role-first.
// src/prisma/db.ts
import { supabase } from '@prisma-next/extension-supabase/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = await supabase<Contract>({
contractJson,
url: process.env['DATABASE_URL'], // direct Postgres connection — see pitfalls
jwksUrl: process.env['SUPABASE_JWKS_URL'], // https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
// Legacy HS256 projects use jwtSecret: process.env['SUPABASE_JWT_SECRET'] instead — exactly one of the two.
});
Options beyond the basics: middleware (same composition as postgres() — see prisma-next-runtime; middleware never sees the role-binding set_config calls), poolOptions, pg (BYO pg.Pool / pg.Client instead of url). Teardown is await db.close() / await using exactly as in prisma-next-runtime — the same script-hang rules apply.
Workflow — Role-bound queries
The concept: bind the role that should execute the request, then query through the returned RoleBoundDb — every query surface from prisma-next-queries works, RLS-filtered by Postgres.
// A signed-in user: rows are RLS-scoped to the JWT's auth.uid().
const userDb = await db.asUser(jwt); // async — throws InvalidJwtError on a bad/expired token
const mine = await userDb.orm.public.Profile.select('id', 'username').all();
// The anon role: sees what anon policies permit.
const listing = await db.asAnon().orm.public.Profile.select('id', 'username').all();
// service_role: BYPASSRLS — sees everything in YOUR contract.
const all = await db.asServiceRole().orm.public.Profile.select('id', 'username').all();
// Writes ride the same surfaces; RLS filters them too. An UPDATE against
// another owner's row affects 0 rows; a withCheck violation raises an error.
const updated = await userDb.orm.public.Profile
.where({ userId: me })
.updateCount({ username: 'new-name' });
Notes: asAnon() / asServiceRole() are sync; only asUser is async. Multi-namespace contracts address models by coordinate (orm.public.Profile, sql.public.profile) — see prisma-next-queries § Namespace-aware accessors. RoleBoundDb.transaction(fn) wraps work in a transaction on the role-bound session.
Workflow — Admin reads of auth.* / storage.*
The concept: Supabase-internal tables are not part of your contract, so they are not on your query surfaces. The service_role binding carries a secondary root — db.asServiceRole().supabase — which is the pack's contract surface:
const admin = db.asServiceRole();
// SQL builder over the pack contract:
const users = await admin.supabase
.execute(admin.supabase.sql.auth.users.select('id', 'email').build())
.toArray();
// ORM over the pack contract:
const sessions = await admin.supabase.orm.auth.AuthSession.select('id', 'aal').all();
// Native enum values (e.g. auth.aal_level) come typed:
type AalLevel = (typeof admin.supabase.nativeEnums.auth.AalLevel)['Value'];
The admin root needs a one-time grant. A real Supabase project gives service_role no table privileges on auth.* / storage.* — out of the box, the reads above fail with permission denied for table users (sqlState 42501). Grant exactly what you read, narrowly:
GRANT USAGE ON SCHEMA auth TO service_role;
GRANT SELECT ON TABLE auth.users TO service_role;
Other boundaries to respect: asUser / asAnon have no .supabase; the admin root has no .transaction (it is a separate contract-bound runtime sharing the pool — a transaction spanning both roots is out of scope); and for user management (creating users, password resets) prefer the GoTrue Admin API — Supabase-internal schemas can drift across platform upgrades; direct service_role SQL is for ad-hoc admin reads.
Workflow — Grants
The concept: RLS policies are row filters on top of ordinary table privileges — a role with policies but no GRANT gets permission denied, not filtered rows. On Supabase the two directions are easy to get backwards:
- Your own
publictables need nothing. Supabase shipsALTER DEFAULT PRIVILEGESonpublic, so tables created byprisma-next db init/migrateinherit full grants foranon/authenticated/service_roleautomatically — the same as dashboard-created tables. RLS policies are what actually protect the rows; do not add per-table grants, and do not narrow the defaults unless you have a reason. - The one grant you do need is for admin reads of Supabase-internal tables —
service_rolehas no table privileges onauth.*/storage.*(see Admin reads above for the narrowGRANT USAGE/GRANT SELECTpair).
Run grants via the Supabase SQL editor or psql. Symptom of a missing grant: permission denied for table … (sqlState 42501) instead of an empty result.
Workflow — Connecting to a real Supabase project
The concept: the runtime needs a direct, session-capable Postgres connection — it binds roles with session-scoped set_config + RESET ALL.
- Session pooler (
aws-0-<region>.pooler.supabase.com:5432, usernamepostgres.<project-ref>) — works everywhere, IPv4. The default choice. - Direct connection (
db.<project-ref>.supabase.co:5432) — works, but is IPv6-only on new projects; from IPv4-only environments it fails DNS/connect. - Transaction pooler (port 6543) — do not use. Transaction pooling breaks session GUCs; role binding will misbehave.
.env carries DATABASE_URL and the JWT key source. For current projects that is SUPABASE_JWKS_URL — https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json (local stack: http://127.0.0.1:54321/auth/v1/.well-known/jwks.json). Only legacy HS256 projects use SUPABASE_JWT_SECRET (Project Settings → API → JWT Secret) — and note supabase status prints a JWT_SECRET even on ES256 projects, so don't infer the mode from its presence; check the JWKS endpoint or a token's header alg.
Common Pitfalls
- Using the transaction pooler (port 6543). Session GUC role binding requires a session-capable connection — use the session pooler (5432) or the direct connection.
- Wiring
jwtSecretbecausesupabase statusprints aJWT_SECRET. Current projects sign ES256;asUserthen throwsInvalidJwtErrorexplaining the token is ES256 and the client needsjwksUrl. ConfigureSUPABASE_JWKS_URL; reservejwtSecretfor legacy HS256 projects. - Grants in the wrong direction. Your
publictables need no grants (Supabase's default privileges cover them; RLS protects the rows) — the grant you need is the narrowauth.*pair forservice_roleadmin reads.permission denied(42501) means a missing grant, not a filtered result. - Expecting
db.sql/db.ormon the top-leveldb. The Supabase db is role-first; bind a role, query theRoleBoundDb. - Forgetting
await— on thesupabase()factory and onasUser(jwt). Both are async;asAnon()/asServiceRole()are not. - Expecting
.supabaseonasUser/asAnon. Admin access toauth.*isservice_role-only by construction — and evenservice_roleneeds the one-time narrow grant first. - A
policy_*block whose target lacks@@rls. Emit fails withPSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE— add@@rlsto the model. - Unquoted camelCase columns or missing casts in predicates. Predicates are verbatim SQL:
"userId"needs quotes; compare uuid toauth.uid()with a::uuidcast where the column isn't alreadyuuid. - Passing both
jwksUrlandjwtSecret(or neither) — thesupabase()promise rejects withSupabaseConfigError. It's an async factory, so the misconfiguration surfaces as a rejection (await/.catch), not a synchronous throw. - Treating an RLS-filtered write as an error. An
UPDATEagainst a row the role can't see affects 0 rows (no exception); onlywithCheckviolations raise.
What Prisma Next doesn't do yet
- No
/controlsubpath on the extension — it can't register through the target façade'sdefineConfig({ extensions: [...] }); wiring goes through the low-level config'sextensionPacksas shown above. File interest viaprisma-next-feedback. GRANTauthoring. Table privileges are not contract elements; the one grant a Supabase app needs (theservice_roleauth.*pair for admin reads) is run once by hand (SQL editor /psql). If you want grants managed by the contract, file viaprisma-next-feedback.- Transactions spanning the app root and the
.supabaseadmin root. The two roots are separate contract-bound runtimes sharing one pool; a cross-root transaction is not supported. - Triggers / functions as contract elements. The classic "create a profile row on signup"
auth.userstrigger is authored as raw SQL against your database, not in the contract.auth.uid()etc. appear only inside opaque policy predicate strings. - Supabase Realtime, storage uploads, PostgREST /
@supabase/supabase-jsinterop, edge runtimes. Out of scope for the extension — it speaks Postgres directly (Node.js / Bun).
Reference Files
examples/supabase— the canonical runnable app: config, contract,db.ts, acceptance tests, README.packages/3-extensions/supabase/README.md— package-level reference (JWT modes, role-binding model, unsupported scope).packages/3-extensions/supabase/src/runtime/supabase.ts— the authoritative options/type surface (SupabaseOptions,RoleBoundDb,ServiceRoleDb).
Checklist
-
extensionPacks: [supabasePack]in the low-leveldefineConfig(no/controlsubpath exists). - Cross-space FK typed
supabase:auth.AuthUserwith explicitfields/references(+onDeleteif wanted). - Every policy target model carries
@@rls; predicates quote camelCase columns and cast forauth.uid(). -
db.tsusesawait supabase<Contract>({ contractJson, url, jwksUrl | jwtSecret })— exactly one JWT key source;jwksUrlfor current projects,jwtSecretonly for legacy HS256. - Queries go through a
RoleBoundDbfromasUser/asAnon/asServiceRole;asUseris awaited. -
auth.*/storage.*reads go throughasServiceRole().supabaseonly, after the one-time narrow grant (GRANT USAGE ON SCHEMA auth+GRANT SELECTon the tables you read). - No per-table grants added for your own
publictables — Supabase default privileges cover them; RLS does the protecting. - Connection is session-capable: session pooler or direct connection — never the 6543 transaction pooler.
- Did NOT confabulate a
/controlsubpath, a top-leveldb.sql,.supabaseon non-service roles, or grant authoring in the contract.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/prisma/prisma-next/prisma-next-supabase">View prisma-next-supabase on skillZs</a>