Skip to content

Search docs

Jump between documentation pages.

Browse docs

Use Supabase with DaloyJS

Supabase is a hosted Postgres + auth + storage platform. The official @supabase/supabase-js client is fetch-based, so it runs on every runtime DaloyJS supports: Node.js, Bun, Deno, and Cloudflare Workers.

Treat Supabase as a platform client, not a traditional ORM: you are composing PostgREST, auth, storage, and realtime APIs rather than mapping tables through model classes.

One request through Supabase
  1. 01clientHTTP requestGET /users/:id + Bearer token
  2. 02preBodyVerified tokenjwk() against the project JWKS
  3. 03supabaseRLS-scoped querystate.supabase.from("users")...
  4. 04responseTyped body200 UserSchema | 404
jwk() verifies the Supabase access token before any body I/O, a per-request client carries that token so Postgres row-level security applies, the destructured error is mapped to problem+json, then the response schema checks the body.

1. Install

bash
pnpm add @supabase/supabase-js

2. Generate database types

Use the Supabase CLI to generate a fully typed schema:

bash
pnpm dlx supabase login
pnpm dlx supabase gen types typescript --project-id <your-ref> --schema public > src/db/supabase.types.ts

3. API keys and the admin client

Use the new API keys from Settings, API Keys in the dashboard:

.envbash
SUPABASE_URL=https://<your-ref>.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_...
src/db/supabase.tsts
import { createClient } from "@supabase/supabase-js";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { App } from "@daloyjs/core";
import type { Database } from "./supabase.types.ts";

export type Db = SupabaseClient<Database>;

// Server-only. The secret key maps to the service_role Postgres role, which
// BYPASSES EVERY ROW-LEVEL SECURITY POLICY. Use it for trusted
// server-to-server work (jobs, webhooks), never for user-facing reads.
export const supabaseAdmin: Db = createClient<Database>(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SECRET_KEY!,
  { auth: { persistSession: false, autoRefreshToken: false } },
);

// Call with the root app, not through app.register(): a decoration made
// inside a registered plugin is visible only to that plugin's own routes.
export function supabasePlugin(app: App) {
  app.decorate("supabaseAdmin", supabaseAdmin);
}

4. Augment app state types

Add the declare module block to the same module that creates the client, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.supabase degrades to any.

src/db/supabase.tsts
// same module as the plugin above
declare module "@daloyjs/core" {
  interface AppState {
    supabaseAdmin: Db;
    /** Per-request client carrying the caller's token, so RLS applies. */
    supabase: Db;
  }
}

5. Verify the token and build an RLS-aware client

Verify the Supabase access token locally with the built-in jwk() against your project's JWKS, instead of calling auth.getUser() over the network on every request. Then build a per-request client that forwards the token, so Postgres evaluates RLS policies as that user.

Register both hooks before the routes they protect (app.use() only applies to routes added after it), and keep them in preBody so a request without a valid token is rejected before any body I/O.

src/auth.tsts
import { createClient } from "@supabase/supabase-js";
import { UnauthorizedError, jwk } from "@daloyjs/core";
import type { App } from "@daloyjs/core";
import type { Database } from "./db/supabase.types.ts";

const SUPABASE_URL = process.env.SUPABASE_URL!;
const BEARER = /^Bearer\s+(\S+)$/i;

export function supabaseAuth(app: App) {
  // 1. 401 on a missing, expired, or forged token. Puts { sub, scopes, claims }
  //    on state.user.
  app.use(
    jwk({
      jwks: `${SUPABASE_URL}/auth/v1/.well-known/jwks.json`,
      algorithms: ["ES256", "RS256"], // match the signing key type you enabled
      issuer: `${SUPABASE_URL}/auth/v1`,
      audience: "authenticated",
    }),
  );

  // 2. A user-scoped client per request. Never reuse it across requests.
  app.use({
    preBody({ headers, state }) {
      const match = BEARER.exec(headers["authorization"] ?? "");
      if (!match) throw new UnauthorizedError("Missing bearer token");
      state.supabase = createClient<Database>(SUPABASE_URL, process.env.SUPABASE_PUBLISHABLE_KEY!, {
        global: { headers: { Authorization: `Bearer ${match[1]}` } },
        auth: { persistSession: false, autoRefreshToken: false },
      });
    },
  });
}

The JWKS endpoint only serves keys once your project uses asymmetric JWT signing keys (JWTs). A project still on the legacy shared HS256 secret has nothing to verify against, and jwk() refuses symmetric algorithms by design: migrate the project to asymmetric signing keys. Supabase's own SDK route to the same check is supabase.auth.getClaims(). Anonymous sign-ins (signInAnonymously()) also get the authenticated role and are marked by the is_anonymous: true claim, so audience: "authenticated" does not exclude them. If you do not accept them, reject state.user.claims.is_anonymous === true in a hook and in your RLS policies. For roles and scopes on top, see requireRoles().

6. Use it in routes

src/server.tsts
import { z } from "zod";
import { App, NotFoundError, secureHeaders } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
import { supabasePlugin } from "./db/supabase.ts";
import { throwPostgrestError } from "./db/supabase-errors.ts";
import { supabaseAuth } from "./auth.ts";

const app = new App();
app.use(secureHeaders());
supabasePlugin(app);
// Public routes go here, before supabaseAuth(). Everything after it needs a token.
supabaseAuth(app);

const UserSchema = z.object({
  id: z.uuid(),
  email: z.email(),
  name: z.string().nullable(),
});

app.get(
  "/users/:id",
  {
    operationId: "getUser",
    request: { params: z.object({ id: z.uuid() }) },
    responses: {
      200: { description: "Found", body: UserSchema },
      401: { description: "Missing or invalid token" },
      404: { description: "Not found" },
    },
  },
  async ({ params, state }) => {
    const { data, error } = await state.supabase
      .from("users")
      .select("id,email,name")
      .eq("id", params.id)
      .maybeSingle();

    if (error) throwPostgrestError(error);
    if (!data) throw new NotFoundError("User not found");
    return { status: 200, body: data };
  },
);

app.post(
  "/users",
  {
    operationId: "createUser",
    request: { body: z.object({ email: z.email(), name: z.string().optional() }) },
    responses: {
      201: { description: "Created", body: UserSchema },
      401: { description: "Missing or invalid token" },
      403: { description: "Blocked by row-level security" },
      409: { description: "Already exists" },
    },
  },
  async ({ body, state }) => {
    // Safe only because z.object() strips unknown keys (no mass assignment).
    const { data, error } = await state.supabase
      .from("users")
      .insert(body)
      .select("id,email,name")
      .single();
    if (error) throwPostgrestError(error);
    return { status: 201, body: data };
  },
);

await app.ready();
serve(app, { port: 3000 });

Mapping Supabase errors

Translate PostgrestError codes into typed framework errors so they serialize as problem+json. Never put error.message in the title: the title is always sent to the client, and PostgREST messages name your tables, columns, and constraints. Log the details server-side and return a generic title.

src/db/supabase-errors.tsts
import type { PostgrestError } from "@supabase/supabase-js";
import { ConflictError, ForbiddenError, HttpError, NotFoundError } from "@daloyjs/core";

export function throwPostgrestError(error: PostgrestError): never {
  switch (error.code) {
    case "23505": // unique_violation
      throw new ConflictError("Resource already exists");
    case "PGRST116": // .single() matched zero (or several) rows
      throw new NotFoundError();
    case "42501": // insufficient_privilege, including RLS rejections
      throw new ForbiddenError();
  }
  console.error("supabase query failed", {
    code: error.code,
    message: error.message,
    details: error.details,
    hint: error.hint,
  });
  throw new HttpError(500, { title: "Internal Server Error" });
}

Declare every mapped status in the route's responses, as the routes above do.

Realtime, storage, and edge functions

The same client exposes storage, functions, and realtime. Use them inside handlers the same way; prefer the per-request state.supabase so storage policies apply to the caller too.

Serverless and edge

The client is fetch-based and holds no connection pool, so there is nothing to close on Lambda, Vercel, Azure Functions, or Workers. If you also connect to the Supabase Postgres directly (Drizzle, Prisma), read Serverless and edge lifecycle and use Supabase's pooler.

Compare with Prisma, Drizzle, Kysely, TypeORM, MikroORM, Sequelize, or the ODM overview for document databases.

For other managed Postgres / MySQL hosts, Neon, PlanetScale, Turso, Cloudflare D1, and Aurora DSQL: see the database hosting overview.

Edit this page on GitHub

Last updated