Hono
Middleware, Result-aware handlers and REST resources matching your OpenAPI document.
import { createHono } from "better-supabase/hono";
import { betterSupabase } from "./lib/supabase";
const bs = createHono(betterSupabase);
const app = bs
.app()
.use("/api/*", bs.middleware())
.get("/api/me", (c) => c.json({ kind: c.var.auth.kind }));
export default app;createHono(betterSupabase) is createServer plus the pieces
below, so bs.admin() and bs.actingAs() work too. bs.app() is
new Hono<typeof bs.Env>() with bs.onError installed.
typeof bs.Env is the app's Hono Env, inferred from the definition, for
sub-apps and helpers that take a Context:
import type { Context } from "hono";
import { Hono } from "hono";
type Env = typeof bs.Env;
const admin = new Hono<Env>();
const tenantOf = (c: Context<Env>) =>
c.var.auth.kind === "user" ? c.var.auth.claims.tenant_id : undefined;bs.Env holds only a type and is undefined at runtime. HonoEnv<Models, Functions, unknown> is the same type with the generics written out.
Middleware
bs.middleware() resolves the caller (Bearer header first, then the session
cookie), rejects anyone not in allow with Problem Details, and sets:
| Variable | Value |
|---|---|
c.var.db | Repositories bound to the caller; RLS applies |
c.var.auth | The resolved AuthState |
c.var.bs | The full server context, including supabase and sql |
app.use("/public/*", bs.middleware({ allow: ["user", "anon"] }));
app.use("/app/*", bs.middleware({ refresh: true })); // browser routes with cookiesTokens are verified locally against the cached JWKS, so the middleware makes
no network call. refresh: true refreshes an expiring cookie session and
adds the new cookies to the response. It is for routes browsers call with
cookies; bearer tokens never refresh.
Entry arrays with toHono
bs.middleware() runs withBetterSupabase in
Hono's middleware slot. To compose it with other @supabase/middleware
entries (withPostgresClient, withCors, your own), pass the array to
toHono. Every contributed key lands on c.var, and Hono's response passes
back through the entries, so refreshed cookies and response headers reach it:
import { Hono } from "hono";
import { withPostgresClient } from "@supabase/server/middleware/postgres";
import { toHono } from "better-supabase/hono";
import { createServer, withBetterSupabase } from "better-supabase/server";
const bs = createServer(betterSupabase);
const app = new Hono()
.use(
toHono([withBetterSupabase(bs, { allow: ["user"] }), withPostgresClient()]),
)
.get("/api/notes", async (c) =>
c.json(await c.var.db.notes.findMany().orThrow()),
);Chain .use() into the routes it gates: Hono carries the contributed keys
through the chained call's type. c.env seeds the pipeline, so on Workers
getEnv inside the entries reads the bindings.
@supabase/server/adapters/hono is deprecated upstream and removed on
2026-12-01. Replace withSupabase from it with toHono([withBetterSupabase(bs)]),
which contributes the same jwtClaims, userClaims and authMode keys.
Typed claims
When the definition has .claims(schema),
c.var.auth, c.var.bs and the handler context are typed by the schema's
output, and so is profile with .userMetadata(schema). bs.app() and
typeof bs.Env carry both types; with a hand-written HonoEnv, pass the
claims as its fourth parameter:
const app = bs
.app()
.use("/api/*", bs.middleware())
.get("/api/role", (c) =>
c.json({
role: c.var.auth.kind === "user" ? c.var.auth.claims.user_role : null,
}),
);Handlers
bs.handler unwraps what the handler returns:
- A
ResultorAsyncResultbecomes JSON on success, or Problem Details on failure. undefinedbecomes204.- A
Responseis sent as is. - A thrown
DbExceptionbecomes its Problem Details.
app.get(
"/api/customers/:id",
bs.handler((c, { db }) => db.customers.findById(c.req.param("id"))),
);
app.post(
"/api/customers",
bs.handler(async (c, { db }) => db.customers.create(await c.req.json()), {
status: 201,
}),
);bs.onError handles errors thrown outside bs.handler. It sends a
DbException as its Problem Details, returns the response of Hono's
HTTPException unchanged, and sends anything else as a 500 without internal
details (unless exposeErrors).
Event sink sends a handler started (see
event sinks) go to
c.executionCtx.waitUntil on Workers. On other runtimes that stop the
invocation after the response, pass the runtime's function:
const bs = createHono(betterSupabase, {
waitUntil: (promise) => EdgeRuntime.waitUntil(promise),
});Resources
bs.resource(table) mounts REST routes that match what
defineApi documents: the same paths, status codes and
bodies.
import { defineListQuery } from "better-supabase/list";
const customerList = defineListQuery(betterSupabase, "customers", {
search: ["name"],
facets: { status: "status" },
sorts: { name: { name: "asc" }, newest: { createdAt: "desc" } },
defaultSort: "newest",
});
app.route(
"/api/customers",
bs.resource("customers", {
list: customerList,
select: ["id", "name", "status"],
input: { create: CustomerInput, update: CustomerPatch },
}),
);| Route | Operation | Success |
|---|---|---|
GET / | list: the list query, or page/size | 200 page |
POST / | create | 201 row |
GET /:id | get | 200 row, 404 when RLS hides it |
PATCH /:id | update | 200 row |
DELETE /:id | delete | 204 |
Views get list and get only. Limit operations with operations; other
methods answer 405 with an Allow header. Integer keys are parsed and
validated. input schemas (any Standard Schema) validate bodies before they
reach the repository.
A cross-site browser request that writes without a bearer token and without
Content-Type: application/json (a form post riding on the session cookie)
gets a 403 with code: "CROSS_SITE_REQUEST". JSON requests from other
origins need a CORS preflight, so your CORS settings decide those. Malformed
keys and bodies answer 400 invalid_input with the reason in detail.
Pass the same options to defineApi(betterSupabase, { resources: { customers: options } })
and the document describes exactly these routes.
Resources also take hooks for business logic around each operation,
actions for custom routes (typed with defineAction from
better-supabase/hono), output when a hook changes the response shape,
and permissions that the server's authorizer checks:
import { createHono, defineAction } from "better-supabase/hono";
const bs = createHono(betterSupabase, { authorizer });
app.route(
"/api/customers",
bs.resource("customers", {
permissions: { delete: "customers.delete" },
actions: {
archive: defineAction({
method: "POST",
path: "/{id}/archive",
permission: "customers.archive",
handler: (ctx, { id }) =>
ctx.db.customers.update(String(id), { status: "archived" }),
}),
},
}),
);A denied permission answers 403 with code: "PERMISSION_DENIED", or
APPROVAL_REQUIRED when the authorizer asks for an approval. See
Resources for hooks, actions and how list
filters rows, and Authorizers for the
contract.
Testing against the local stack
signLocalJwt from better-supabase/testing signs a token with the local
stack's ES256 key (see signing key). The API
verifies it against the stack's JWKS, as it would in production, and sees
the same claims that PostgREST and RLS see:
import { signLocalJwt } from "better-supabase/testing";
const bs = createHono(betterSupabase);
const token = await signLocalJwt({ sub: userId, tenant_id: organizationId });
await app.request("/api/customers", {
headers: { authorization: `Bearer ${token}` },
});Last updated on