My stack The stack · Data and the API layer

Postgres, Drizzle and Hono

One schema, typed end to end — and a server that does not care which runtime it lands in.

1Hono app in the NotebookLM build, mounted by both the web and the desktop shell
18tables there, five of them embedding tables — one per dimension
2databases by design: Postgres where data is shared, SQLite where it is one device’s
01Why Hono, and why it keeps coming up

Hono is a small router built on Web standard Request and Response, which means the same app object runs on Cloudflare Workers, on Bun, on Node and inside an Electron process without being rewritten. That is not a convenience — it is what lets one server be mounted by two completely different shells.

The self-hosted NotebookLM is the clearest case: a Next.js deployment mounts the Hono app under a catch-all route, and the Electron desktop build mounts the same app through its dev server and then as a bundle. Same routes, same handlers, same behaviour.

02One server, two shells, one adapter The only thing that differs is where the data lives.
The platform adapter

Hosted or entirely local, from one codebase.

Next.js on Vercelhosted
Electronlocal-first
one Hono appsame routes, both
PlatformAdapterdb, storage, auth
Neon or PGlitehosted or embedded

Behaviour is identical because the only thing that differs is an adapter holding the database, the storage and the auth — Neon and S3 on the hosted side, an embedded Postgres and the local filesystem on the desktop. That is why the desktop build works with no account and no network: nothing above the adapter knows which one it got.

03Drizzle, and what it is actually for A schema you can read, and types that come from it.
The schema is TypeScript

Tables are declared in code, so the types the application uses are derived from the schema rather than written twice and kept in sync by hand.

Migrations are files you can read

Generated as SQL, reviewed in the pull request like anything else. An ORM that hides the migration is an ORM that surprises you during a deploy.

It stays close to SQL

Queries look like the SQL they become, which matters the moment one is slow — you can read the query and the plan without translating between two mental models.

Five embedding tables, on purpose

One per vector dimension, so changing embedding model is non-destructive: retrieval reads only rows written by the model currently configured, and the old rows stay put.

04When it is not Postgres The other answer, and the line between them.
Shared versus single-device

The question is not scale. It is how many machines need the same row.

many clients, one truthPostgres + Drizzle
one device's own dataSQLite

Tether’s API uses bun:sqlite with no ORM at all, because its data belongs to one machine and a file is the correct shape for that. Mercala uses Postgres with row-level security because many tenants share one database and the isolation has to be enforced below the application. Neither is a compromise — they are answers to different questions, and using the wrong one shows up as either needless operational weight or a data race you cannot fix in the application layer.

An ORM that hides the migration is an ORM that surprises you during a deploy.

the working rule across these repos
05One repository: web, mobile and the backend Every TypeScript product I build has the same shape, and the shape is the point.
apps/ and packages/

The web app, the phone app and the server that feeds them share one set of types.

apps/webNext.js
apps/mobileExpo
apps/serverHono or Bun
packages/*schemas, client, tokens, db
5Coursified apps
13packages they share
1place a shared type is declared

Coursified is five apps — web, mobile, server, relay, fetcher — over thirteen packages. Voxtar is six apps over three packages plus a Python service. Tether is two apps over one. The counts differ; the shape never does: deployables in apps/, anything two of them share in packages/. The reason is narrow and it is about when you find out you were wrong. A shared type in a package means changing it breaks the build of every app that imports it, in CI, before anything ships. The same type copied into a web repo and a mobile repo means you find out from a user.

06What earns its own package Not everything. The test is whether two apps disagree if it drifts.
Contracts

schemas and api-client. The server validates with the same schema the client sends against, so the wire format has one definition rather than two that agree today.

Tokens

Colour and type, read by web and mobile alike — which is the only reason the phone build still looks like the product.

The database layer

db as a package means the schema, the migrations and the query helpers travel together, and nothing reaches past them into raw SQL by accident.

Auth

One implementation of who-you-are, because two is how a mobile app ends up trusting a token the web app would have rejected.

What does not

A helper used once. A package with one consumer is a directory with extra ceremony, and I have deleted more of those than I have kept.

07The part people get wrong A monorepo does not make versions agree. It makes disagreement visible.
Pin what must be singular

React, React Native and CodeMirror are pinned in root overrides. Workspaces resolve compatible-but-different versions perfectly happily, and for these that is a bug.

Coursified proved it

Two copies of @codemirror/state are two nominal types, because its classes carry private fields. Every EditorView passed between them failed to typecheck. That broke production on 16 September 2026.

The pin lives in package.json

Not the lockfile — because the incident was Vercel's bun failing to parse the lockfile and resolving fresh. A fix that only exists in a lockfile is a fix that assumes the lockfile is read.

Write the reason next to it

The overrides block carries a paragraph explaining why. A pin without its reason is the next person's bug, and the next person is usually me.

08The repository
The case study

Self-hosted NotebookLM

One Hono app, eighteen tables through Drizzle, and a platform adapter that lets the same server run hosted or entirely on your machine.

Typed from the schema outward, and portable by default.

Drizzle means the types come from the tables rather than beside them; Hono means the server is not married to the runtime it happens to start in.