# Hono

> Middleware, Result-aware handlers and REST resources matching your OpenAPI document.

Source: https://bettersupabase.com/docs/frameworks/hono

```ts title="src/server.ts"
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`](/docs/auth/server) plus the pieces
below, so `bs.admin()` and `bs.actingAs()` work too. `bs.app()` is
`new Hono<typeof bs.Env>()` with [`bs.onError`](#handlers) installed.
`typeof bs.Env` is the app's Hono `Env`, inferred from the definition, for
sub-apps and helpers that take a `Context`:

```ts
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 [#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` |

```ts
app.use("/public/*", bs.middleware({ allow: ["user", "anon"] }));
app.use("/app/*", bs.middleware({ refresh: true })); // browser routes with cookies
```

Tokens 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` [#entry-arrays-with-tohono]

`bs.middleware()` runs [`withBetterSupabase`](/docs/auth/middleware) 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:

```ts
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 [#typed-claims]

When the definition has [`.claims(schema)`](/docs/auth#typed-claims),
`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:

```ts
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 [#handlers]

`bs.handler` unwraps what the handler returns:

* A `Result` or `AsyncResult` becomes JSON on success, or Problem Details on
  failure.
* `undefined` becomes `204`.
* A `Response` is sent as is.
* A thrown `DbException` becomes its Problem Details.

```ts
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](/docs/standards/events#sends-after-the-response)) go to
`c.executionCtx.waitUntil` on Workers. On other runtimes that stop the
invocation after the response, pass the runtime's function:

```ts
const bs = createHono(betterSupabase, {
  waitUntil: (promise) => EdgeRuntime.waitUntil(promise),
});
```

## Resources [#resources]

`bs.resource(table)` mounts REST routes that match what
[`defineApi`](/docs/specs) documents: the same paths, status codes and
bodies.

```ts
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:

```ts
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](/docs/specs/resources) for hooks, actions and how `list`
filters rows, and [Authorizers](/docs/extending/authorizers) for the
contract.

## Testing against the local stack [#testing-against-the-local-stack]

`signLocalJwt` from `better-supabase/testing` signs a token with the local
stack's ES256 key (see [signing key](/docs/testing#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:

```ts
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}` },
});
```