# Hono

You get one Hono app that serves a read route and a write route, and two small entry files that run it on Bun and on Node.js with the same postgres.js driver. CI boots both.

## Runtime and driver

| | |
| --- | --- |
| Runtime | Bun 1.4 or newer, or Node.js 22.18 or newer |
| Driver | `okmodel/pg/postgresjs` + `postgres` |
| Example | `examples/hono` |

`okmodel/pg/bun` loads only on Bun. The example uses postgres.js so one driver covers both runtimes.

## Install

```sh
bun add okmodel postgres hono @hono/node-server
```

`@hono/node-server` is only for the Node.js entry.

## `db` module and lifecycle

There is one client per process, opened at module level in `src/db.ts`. It throws at import when `DATABASE_URL` is missing, because `connect` does not read the environment. Both entry files call `closeDb()` on `SIGINT` and `SIGTERM`.

```ts title="src/db.ts" no-check="imports ./schema.js, which re-exports a file outside the example root (shown below)"
/**
 * Process-scoped client for the Hono example (Node and Bun).
 */

import { connect } from "okmodel/pg/postgresjs";

import { app } from "./schema.js";

const url = process.env.DATABASE_URL;
if (url === undefined || url.length === 0) {
  throw new Error("DATABASE_URL is required");
}

/** Shared client for this process. */
export const db = connect(url, { schema: app });

/** Closes the process client. */
export async function closeDb(): Promise<void> {
  await db.close();
}
```

`src/schema.ts` re-exports the table every framework example shares:

```ts title="src/schema.ts" no-check="re-exports ../../shared/schema.js, which is outside the example root"
export { app, notes } from "../../shared/schema.js";
```

```ts title="shared/schema.ts"
/**
 * Shared notes table for framework examples.
 *
 * Each example imports this schema. Tests create an isolated database and
 * apply the matching SQL before booting the app.
 */

import { schema, table, t } from "okmodel/pg";

/** Notes table used by every framework example. */
export const notes = table("example_notes", {
  id: t.identity(),
  title: t.text(),
});

/** Schema for the examples. */
export const app = schema({ tables: [notes] });

/** SQL that creates {@link notes} on an empty database. */
export const NOTES_DDL = `
create table example_notes (
  id bigint generated by default as identity primary key,
  title text not null
);
`;
```

In your own app, define the table in `src/schema.ts` directly. The table is named `example_notes`, so the client property is `db.example_notes`.

## Connection config

| Setting | Where | Notes |
| --- | --- | --- |
| `DATABASE_URL` | Environment of the Bun or Node.js process | A direct TCP URL such as `postgres://user:pass@host:5432/app`. |
| `PORT` | Environment | Both entries listen on `PORT`, default `3102`. |
| `max` | `connect` option | Up to 10 connections per process when omitted. [Pool](/docs/operate/session-and-pool#max). |
| `ssl` | `connect` option | Forwarded to postgres.js for hosted databases. [Pool](/docs/operate/session-and-pool#tls). |
| `prepared` | `connect` option | Leave it unset behind a pooler in transaction mode. [Pool](/docs/operate/session-and-pool#prepared). |

## One route that reads and writes

`GET /notes` returns the oldest 50 notes. `POST /notes` takes `{ "title": "..." }` and returns the new row with status 201. The route turns a body that is not JSON into an empty object, so it gets the same 400 as a missing `title`. `GET /health` does not touch the database; it is the readiness check the test waits on, and the one to point a load balancer at.

`POST /notes` has no authentication and no body size limit. Add both before you copy it.

```ts title="src/app.ts" no-check="imports hono, which is installed in examples/hono"
/**
 * Hono routes shared by the Bun and Node servers.
 */

import { Hono } from "hono";

import { db } from "./db.js";

/** Application routes. */
export const app = new Hono();

app.get("/health", (c) => c.json({ ok: true }));

app.get("/notes", async (c) => {
  const rows = await db.example_notes.find({
    orderBy: { id: "asc" },
    limit: 50,
  });
  return c.json(rows);
});

app.post("/notes", async (c) => {
  const body = await c.req.json<{ title?: string }>().catch(() => ({}));
  const title = typeof body.title === "string" ? body.title : undefined;
  if (title === undefined || title.length === 0) {
    return c.json({ error: "title is required" }, 400);
  }
  const row = await db.example_notes.insert({ title });
  return c.json(row, 201);
});
```

The two entries differ only in how they serve `app.fetch`. On Bun:

```ts title="src/server.ts" no-check="uses the Bun global, and imports ./db.js, which needs the schema file outside the example root"
/**
 * Hono on Bun (`bun ./src/server.ts`).
 */

import { app } from "./app.js";
import { closeDb } from "./db.js";

const port = Number(process.env.PORT ?? "3102");

Bun.serve({
  port,
  fetch: app.fetch,
});

console.log(`hono (bun) example on ${port}`);

process.on("SIGINT", () => void closeDb());
process.on("SIGTERM", () => void closeDb());
```

On Node.js:

```ts title="src/server-node.ts" no-check="imports @hono/node-server, which is installed in examples/hono"
/**
 * Hono on Node via `@hono/node-server`.
 */

import { serve } from "@hono/node-server";

import { app } from "./app.js";
import { closeDb } from "./db.js";

const port = Number(process.env.PORT ?? "3102");

serve({ fetch: app.fetch, port }, () => {
  console.log(`hono (node) example on ${port}`);
});

process.on("SIGINT", () => void closeDb());
process.on("SIGTERM", () => void closeDb());
```


## Dev and test

Start Postgres, point `DATABASE_URL` at it, create the table, then run either entry. In this repository, `bun run db:up` starts a local Postgres whose primary URL is below.

```sh
bun run db:up
export DATABASE_URL=postgres://okm:okm@127.0.0.1:55432/okm
```

The example has no `okmodel.config.ts`. In your app, add one next to `package.json`:

```ts title="okmodel.config.ts"
import { defineConfig } from "okmodel/migrate";

export default defineConfig({
  schema: "./src/schema.ts",
  database: process.env.DATABASE_URL!,
});
```

Create the table on a fresh database with `okm push`, which writes no SQL file and suits a database you can drop:

```sh
bunx okm push
```

```text
target default
applied push-1
```

For a database you will deploy, write the first migration and apply it, then commit `migrations/`. After a later schema change, run `bunx okm push` again on a dev database, or `bunx okm generate <name>` then `bunx okm migrate apply`.

```sh
bunx okm generate init
bunx okm migrate apply
```

Run the Bun entry, or the Node.js entry. The `dev:node` script bundles `src/server-node.ts` with `bun build --target=node --packages=external`, so Node.js loads `postgres` from `node_modules`. Without `DATABASE_URL` in that shell, either entry stops at start with `Error: DATABASE_URL is required`.

```sh
cd examples/hono
bun run dev
PORT=3112 bun run dev:node
```

Call it from another terminal:

```sh
curl -i http://localhost:3102/health
curl -i -X POST http://localhost:3102/notes -H 'content-type: application/json' -d '{"title":"hello"}'
curl -i http://localhost:3102/notes
```

These responses come from the Bun entry on a fresh database. `id` is a string, because the default codec for `t.identity()` is a string.

| Request | Status | Body |
| --- | --- | --- |
| `GET /health` | 200 | `{"ok":true}` |
| `POST /notes` with `{"title":"hello"}` | 201 | `{"id":"1","title":"hello"}` |
| `GET /notes` | 200 | Up to 50 rows, oldest first: `[{"id":"1","title":"hello"}]` |
| `POST /notes` with `{}` | 400 | `{"error":"title is required"}` |
| `POST /notes` with `nope` as the body | 400 | `{"error":"title is required"}` |

The example's own test does not run `okm`: it creates an isolated database and applies `NOTES_DDL` through `examples/shared/support.ts`, which is faster. To run it from a clone:

```sh
bun run build
bun run db:up
bun install --cwd examples/hono
cd examples/hono && REQUIRE_DOCKER=1 bun test ./hono.test.ts
```

## Deploy and migrate-on-deploy

Deploy the Bun entry or the bundled Node.js entry with `DATABASE_URL` set. Keep one client per process; do not open one per request.

Apply migrations before the new version takes traffic. Run this in your release job, with `DATABASE_URL` pointing at a direct connection to the primary, not a pooler:

```sh
bunx okm migrate apply
```

A migration that only adds things lets the old version keep running while the release rolls out ([Migrations in CI](/docs/guides/migrations-in-ci), [Environments](/docs/migrations/environments)).

## Limits and gotchas

- Run the Node.js entry from a bundle, not from the `.ts` files: the sources import `./db.js` and `./app.js`.
- This app serves long-lived processes. For an isolate platform, see [Cloudflare Workers](/docs/frameworks/workers).
- The route returns at most 50 rows. To read past them, page through the rest ([Read rows](/docs/queries/read-rows)).
- The signal handlers close the pool and do not stop the listener. On Bun, a `SIGTERM` leaves `Bun.serve` answering `GET /health` while every query fails on a closed pool. Have your process manager follow `SIGTERM` with `SIGKILL` after a grace period, or exit from your own handler once `closeDb()` resolves.

| Symptom | Cause | Fix |
| --- | --- | --- |
| `Error: DATABASE_URL is required` at start | `src/db.ts` ran with no `DATABASE_URL` | Set the variable where the process runs. |
| [OKM1520](/docs/reference/errors#okm1520) `The database is behind the app` on the first query | The app was deployed before `okm migrate apply` | Apply the migrations, then start the app. |
| [OKM1101](/docs/reference/errors#okm1101) `find() needs a limit.` | A read with no `limit` | Pass `limit`, as the example route does. |
| `OkmError: relation "example_notes" does not exist` (SQLSTATE `42P01`) | The table was never created on this database | Run `bunx okm push`, or `okm generate init` then `okm migrate apply`. |
| 500 `Internal Server Error` with a plain-text body | An unhandled error in a route, such as `42P01` above or a lost connection. The example has no `app.onError`, so Hono answers with its default | Read the error in the process log. Add `app.onError` to return JSON. |
| `Failed to start server. Is port 3102 in use?` on Bun | An earlier copy of the app still holds the port, often one that ignored `SIGTERM` | Stop it by process ID, or set `PORT`. |
| `Refusing pooler host <host>. Apply needs a direct connection.` | `okm` pointed at a pooler URL | Point `okm` at the direct host. |

## What is tested

| Evidence | Where | What runs |
| --- | --- | --- |
| CI | `examples / frameworks` job | `hono.test.ts` boots `bun ./src/server.ts`, then the Node.js bundle with `node`, each on a fresh database with `NOTES_DDL` applied. Each waits for `/health`, then checks `POST /notes` (201) and `GET /notes`. |
| Manual | By hand | `okm push` and the `curl` calls above against the Bun entry, including the 400s. With the table missing, the `POST` returned a plain-text 500. A `SIGTERM` closed the pool and left `/health` answering. |
| Not covered | | `okm migrate apply` against this example, and any hosted deployment. |

The frameworks job runs on pull requests that touch `examples/`, the runtime, the dialects, or the Frameworks pages, and after each merge.

Index: [/llms.txt](/llms.txt)
