Edge Functions
Fetch handlers for Supabase Edge Functions, Deno, Bun and Workers.
import { createEdge } from "better-supabase/edge";
import { betterSupabase } from "../_shared/supabase.ts";
const bs = createEdge(betterSupabase, { cors: true });
Deno.serve(
bs.handler(async (request, { db }) => {
const { name } = await request.json();
return db.customers.create({ name, organizationId: "..." });
}),
);bs.handler(fn) returns a plain (request) => Promise<Response>. The handler
runs as the caller, so RLS applies, and its return value is converted the
same way as in the other adapters:
- A
Resultbecomes JSON on success, or Problem Details on failure. undefinedbecomes204.- A
Responseis sent as is. - A thrown
DbExceptionbecomes its Problem Details, and any other error a 500.
Anyone not in allow (default ['user']) gets a 401 or 403 before your code
runs.
For a function the app calls with a typed input and output, use
defineFunction: it checks the body with a
Standard Schema and exports a contract for functions() in
better-supabase/client.
With .claims(schema) on the definition, auth
in the handler is typed by the schema's output:
Deno.serve(
bs.handler((_request, { auth }) => ({
role: auth.kind === "user" ? auth.claims.user_role : null,
})),
);REST resources
bs.resources serves the same REST routes as Hono resources
without a router:
Deno.serve(
bs.resources(
{
customers: { list: customerList },
tags: { operations: ["list", "get"] },
},
{ basePath: "/api" },
),
);On Supabase, the function name is the first path segment, so basePath is
/<function-name>. The longest matching table path wins; an unknown path
answers 404.
Each entry takes the same options as a Hono resource, including hooks,
actions (typed with defineAction from better-supabase/edge) and
permissions. Pass authorizer to createEdge to decide them:
import { createEdge, defineAction } from "better-supabase/edge";
const bs = createEdge(betterSupabase, { cors: true, authorizer });
Deno.serve(
bs.resources(
{
customers: {
permissions: { delete: "customers.delete" },
actions: {
archive: defineAction({
method: "POST",
path: "/{id}/archive",
handler: (ctx, { id }) =>
ctx.db.customers.update(String(id), { status: "archived" }),
}),
},
},
},
{ basePath: "/api" },
),
);See Resources for hooks, actions and permissions.
Routes
bs.routes serves several handlers from one function. Keys are
"METHOD /path" or "/path" (any method), with :name segments and a
trailing /*. params in each handler is typed from its key. A route can be
a handler or an object with the same guards as Hono's bs.require:
Deno.serve(
bs.routes(
{
"GET /customers/:id": (_request, { db, params }) =>
db.customers.findById(params.id),
"POST /customers/:id/archive": {
roles: ["admin"],
roleClaim: "user_role",
requireTenant: true,
handler: (_request, { db, params }) =>
db.customers.update(params.id, { status: "archived" }),
},
},
{ basePath: "/api" },
),
);An unknown path returns 404, and a known path with another method returns 405
with an Allow header. A route object can also name a permission, which
the authorizer passed to createEdge decides with { type: "route" } as
the resource; without an authorizer, the route refuses every caller.
Background work
Event sink sends that a handler started (see
event sinks) are handed to
waitUntil, so the runtime keeps the invocation alive until they finish. On
Workers the handler uses the ctx that fetch(request, env, ctx) receives.
On Supabase, pass the runtime's function:
const bs = createEdge(betterSupabase, {
waitUntil: (promise) => EdgeRuntime.waitUntil(promise),
});CORS
cors: true answers preflights and adds the headers supabase-js sends
(authorization, x-client-info, apikey, content-type) for any origin.
Restrict it with an allow-list:
createEdge(betterSupabase, {
cors: { origin: ["https://app.example.com"], maxAge: 600 },
});A request from an origin that isn't listed gets no CORS headers, and error
responses get the same headers as successful ones. The headers come from
withCors in @supabase/middleware/cors: a preflight is an OPTIONS
request with Access-Control-Request-Method, and an allow-list adds
Vary: Origin. corsConfig(options) returns the withCors config for your
own pipeline.
Entry arrays with toEdge
toEdge(entries, handler) runs an @supabase/middleware entry array around
a handler and returns the same fetch handler shape. The host's second
argument seeds the pipeline, so on Workers getEnv inside the entries reads
the bindings:
import { withCors } from "@supabase/middleware/cors";
import { toEdge } from "better-supabase/edge";
import { createServer, withBetterSupabase } from "better-supabase/server";
const bs = createServer(betterSupabase);
export default {
fetch: toEdge(
[withCors({ origin: "https://app.example.com" }), withBetterSupabase(bs)],
async (_request, ctx) =>
Response.json(await ctx.db.notes.findMany().orThrow()),
),
};The handler gets the contributions and the Workers execution context, and
returns a Response. Put withCors first so a 401 from the guard carries the
CORS headers too.
Last updated on