Monorepos
One runtime package owns defineSupabase and the request context; domain packages type their repositories from its db and never import the generated client.
In a monorepo, keep one package that owns the Supabase definition and the
request context, and let every domain package take its db as a parameter.
The generated client then has one importer, a schema change regenerates one
file, and domain code works the same in a route handler, a job or a test.
packages/
runtime/ defineSupabase, the generated client, the server, the context types
crm/ customers repositories: import type { Db } from '@acme/runtime'
commerce/ shipping repositories: import type { Db } from '@acme/runtime'
apps/
web/ bs.route and bs.action call crm and commerce with ctx.dbThe runtime package
import { defineSupabase } from "better-supabase";
import { createEdge, type EdgeOptions } from "better-supabase/edge";
import {
type AuthSession,
type ServerContext,
toSession,
} from "better-supabase/server";
import { type Functions, type Models, schema } from "./generated.ts";
export const betterSupabase = defineSupabase(schema);
export type AppContext = ServerContext<Models, Functions, unknown>;
export type Db = AppContext["db"];
export function sessionOf(ctx: AppContext): AuthSession {
return toSession(ctx.auth);
}
export function createRuntime(options: EdgeOptions) {
return createEdge(betterSupabase, options);
}Point better-supabase gen at this package ("output": "src/generated.ts" in
its better-supabase.config.json). A Next.js app calls createNext(betterSupabase, ...)
with the same betterSupabase, so every adapter shares one definition.
The CLI in a workspace
Run the CLI from any package, or pass --cwd packages/runtime. The
supabase directory usually stays at the repository root, and the CLI finds
it the way the Supabase CLI does: it reads supabase/config.toml from --cwd
or the nearest parent directory that has one, and stops at the directory that
holds .git. Ports, Auth hooks, schema_paths and the migrations then come
from that directory, and keys writes signing_keys.json next to it.
better-supabase.config.* is found the same way: the CLI loads the first one
it meets walking up from --cwd, so a single config at the repository root
serves every package, and pnpm --filter @acme/runtime exec better-supabase gen
needs no --cwd. Paths inside the config are relative to the directory that
holds the config file. --config takes precedence and stays relative to
--cwd.
With the config in the package, point the SQL modules and the seed at the root
supabase directory when you use them:
export default defineConfig({
output: "src/generated.ts",
sql: { dir: "../../supabase/schemas", testsDir: "../../supabase/tests" },
seed: { output: "../../supabase/seeds/000_better_supabase.sql" },
});@supabase/postgrest-typegen, which gen loads to match supabase gen types, pins oxfmt as an optional peer. When the workspace uses a newer
oxfmt, pnpm reports a peer mismatch; allow it in pnpm-workspace.yaml:
peerDependencyRules:
allowedVersions:
"@supabase/postgrest-typegen>oxfmt": "*"Domain packages
Domain packages import only types from the runtime:
import type { Db } from "@acme/runtime";
export function activeCustomers(db: Db) {
return db.customers.findMany({
select: ["id", "name", "status"],
where: { status: "active" },
orderBy: { name: "asc" },
});
}Rows keep the runtime's casing, the status column keeps its enum type, and
the function returns a Result, so a route handler can return it as is.
Because Db is a type import, the domain package has no runtime dependency
on the generated client and no import cycle with the app.
The
monorepo example
has this layout as workspace packages: runtime, crm and billing, and a
Hono api that passes the caller's db to both. Its domain packages carry a
Turbo boundaries tag that rejects a dependency from one domain package on
another:
{
"boundaries": {
"tags": { "domain": { "dependencies": { "deny": ["domain"] } } }
}
}To keep the boundary inside one package, add a test that fails when a domain
file imports anything but the runtime. The
validation-monorepo
workspace in this repository has one, plus type tests that pin each
repository's parameter to the runtime's Db.
Bearer callers in domain code
sessionOf(ctx) gives domain code the caller as plain data. For an OAuth
client or an agent, session.actor and session.delegation say who acts
for the user and with which scopes (see
bearer callers). Pass the session
down when a domain function records who made a change; RLS keeps deciding
the rows.
Errors across packages
Domain packages that use better-result
convert at the boundary with toBetterResult(result, Result, mapError) and
back with fromBetterResult(value). A DbError, or an error whose cause
is one, survives the round trip; other errors go through mapError. Both
work on Result and AsyncResult.
Last updated on