better-push
Integrate your stackWalkthroughs

Hono walkthrough

better-push on a Hono API, with the emulator standing in for a client.

See it running and inspect apps/demos/hono.

A Hono API server, from nothing to a notification you can see. Hono serves an API rather than a browser, so there is no service worker and no permission prompt - the devices are whatever clients you serve, and the emulator stands in for one while you build.

You need a Hono project on Node with a DATABASE_URL.

1. Scaffold

npx @better-push/cli init --framework hono
Detected Hono · node-postgres (pg) · package manager pnpm · source root src

Planned changes
create src/push.ts
create src/push-route.ts
create better-push.sql
env    .env.local (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, …)
note   No service worker was written: this framework serves an API, not a
       browser. Register devices by POSTing to /api/push/devices from whatever
       client you serve.

2. Install and mount

pnpm add @better-push/core pg
src/index.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { pushRoutes } from "./push-route";

const app = new Hono();
app.route("/", pushRoutes);

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

Do not compress the event stream

GET /api/push/events is SSE. A compression middleware buffers it, turning a live feed into one long silence followed by everything at once. Exclude that path, or mount compression after pushRoutes.

3. Create the tables

npx @better-push/cli migrate

4. Wire your session

Hono has no session convention, so this is yours to write. The resolver gets the raw Request:

src/push.ts
session: async (request) => {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  const user = token ? await verify(token) : null;
  return user ? { userId: user.id } : null;
},

5. Check it

npx @better-push/cli doctor

It knows this is Hono, finds src/push-route.ts, and does not complain about a missing service worker - an API server has no page to register one from.

6. See a notification

src/push.ts
import { emulator } from "@better-push/core/providers/emulator";

providers: [
  webPush({ vapid: { /* ... */ } }),
  ...(process.env.NODE_ENV === "production" ? [] : [emulator()]),
],
npx @better-push/cli dev

There is no browser here, so register the virtual device with a request - which is exactly what your real clients will do:

curl -X POST http://localhost:3000/api/push/devices \
  -H "authorization: Bearer $YOUR_TOKEN" \
  -H "content-type: application/json" \
  -d '{"platform":"ios","provider":"emulator","token":"emulator-dev-1"}'

Then send:

await push.notify({ userId: "alice", title: "Hello", body: "It works." });

It appears at http://127.0.0.1:4984, tagged ios because that is what the device registered as.

7. The real clients

Hono usually fronts a mobile app or a separate frontend. Both register the same way:

  • React Native: @better-push/core/native handles the permission flow, the token, and re-registration on rotation.
  • A separate web frontend: usePushRegistration from @better-push/core/react, with baseURL pointing at this API. Serve sw.js from the frontend's origin, not this one - a service worker's scope is its own origin.

Where to go next

On this page