TanStack Start
A typed Postgres client in TanStack Start server routes on Node.js, with the example's files, a migrate step, and what CI proves.
You get one db module that TanStack Start server routes import, a /api/notes route that reads and writes a table, and the steps to create that table on an empty database. The server runs on Node.js through Vite and Nitro.
Runtime and driver
| Runtime | Node.js 22.18 or newer. Bun installs packages and runs scripts. |
| Not covered | Edge and worker targets. Use Cloudflare Workers with Neon there. |
| Driver | okmodel/pg/postgresjs + postgres |
| Example | examples/tanstack-start |
Install
bun add okmodel postgres @tanstack/react-start @tanstack/react-router nitro react react-dombun add -D vite @vitejs/plugin-react @tanstack/router-cli @types/node @types/react @types/react-dom typescriptStart from the exact TanStack and Nitro versions the example pins in package.json. The Vite config adds the Nitro plugin ahead of the TanStack Start plugin:
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
export default defineConfig({
server: { port: 3105 },
resolve: { tsconfigPaths: true },
plugins: [nitro(), tanstackStart(), viteReact()],
});db module and lifecycle
There is one client per server process, opened at module level in src/db.ts, and every server handler shares its pool. It throws at import when DATABASE_URL is missing, because connect does not read the environment.
/**
* Process-scoped client for the TanStack Start example.
*/
import { connect } from "okmodel/pg/postgresjs";
import { app } from "./schema";
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();
}import { schema, table, t } from "okmodel/pg";
export const notes = table("example_notes", {
id: t.identity(),
title: t.text(),
});
export const app = schema({ tables: [notes] });Import src/db.ts only from server handlers: it reads DATABASE_URL and imports a TCP driver, and neither belongs in the browser bundle. Nothing in the example calls closeDb(); call it from your own shutdown code or from a script.
Connection config
| Setting | Where | Notes |
|---|---|---|
DATABASE_URL | Environment of vite dev and of the built server | A direct TCP URL such as postgres://user:pass@host:5432/app. |
max | connect option | Up to 10 connections per process when omitted. Pool. |
ssl | connect option | Forwarded to postgres.js for hosted databases. Pool. |
prepared | connect option | Leave it unset behind a pooler in transaction mode. Pool. |
One route that reads and writes
GET /api/notes returns the oldest 50 notes. POST /api/notes takes { "title": "..." } and returns the new row with status 201. It has no authentication and no body size limit; add both before you copy it.
import { createFileRoute } from "@tanstack/react-router";
import { db } from "../../db";
export const Route = createFileRoute("/api/notes")({
server: {
handlers: {
GET: async () => {
const rows = await db.example_notes.find({
orderBy: { id: "asc" },
limit: 50,
});
return Response.json(rows);
},
POST: async ({ request }) => {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "body must be JSON" }, { status: 400 });
}
const title =
body !== null && typeof body === "object" && "title" in body
? (body as { title: unknown }).title
: undefined;
if (typeof title !== "string" || title.length === 0) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const row = await db.example_notes.insert({ title });
return Response.json(row, { status: 201 });
},
},
},
});The second route, src/routes/api/health.ts, returns { "ok": true } without a query, so it answers while the database is down. It is the readiness check the test waits on, and the one to point a load balancer at. src/routeTree.gen.ts is generated by the example's generate-routes script (bunx tsr generate); regenerate it after you add a route file.
Dev and test
Start Postgres, point DATABASE_URL at it, create the table, then run the dev server. In this repository, bun run db:up starts a local Postgres whose primary URL is below.
bun run db:up
export DATABASE_URL=postgres://okm:okm@127.0.0.1:55432/okmThe example has no okmodel.config.ts. In your app, add one next to package.json:
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:
bunx okm pushtarget default
applied push-1For 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.
bunx okm generate initbunx okm migrate applyStart the dev server in the same shell. Without DATABASE_URL there, every route answers 500, /api/health included, because loading the route tree imports src/db.ts; the terminal prints Error: DATABASE_URL is required.
bunx vite dev --port 3105Call it from another terminal:
curl -i http://localhost:3105/api/health
curl -i -X POST http://localhost:3105/api/notes -H 'content-type: application/json' -d '{"title":"hello"}'
curl -i http://localhost:3105/api/notesThese responses come from vite dev on a fresh database. id is a string, because the default codec for t.identity() is a string.
| Request | Status | Body |
|---|---|---|
GET /api/health | 200 | {"ok":true} |
POST /api/notes with {"title":"hello"} | 201 | {"id":"1","title":"hello"} |
GET /api/notes | 200 | Up to 50 rows, oldest first: [{"id":"1","title":"hello"}] |
POST /api/notes with {} | 400 | {"error":"title is required"} |
POST /api/notes with nope as the body | 400 | {"error":"body must be JSON"} |
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:
bun run build
bun run db:up
bun install --cwd examples/tanstack-start
cd examples/tanstack-start && REQUIRE_DOCKER=1 bun test ./tanstack-start.test.tsDeploy and migrate-on-deploy
bunx vite build is the example's build script and produces the Nitro server output. Deploy that output to a Node.js host with DATABASE_URL set.
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:
bunx okm migrate applyA migration that only adds things lets the old version keep running while the release rolls out (Migrations in CI, Environments).
Limits and gotchas
- The Nitro build output is not run in CI. Check it in your own pipeline.
- The route returns at most 50 rows. To read past them, page through the rest (Read rows).
- A new route file under
src/routes/is not registered untilbun run generate-routesregeneratessrc/routeTree.gen.ts.
| Symptom | Cause | Fix |
|---|---|---|
Error: DATABASE_URL is required, and 500 on every route | src/db.ts ran with no DATABASE_URL | Set the variable where the server runs. |
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 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. |
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 | tanstack-start.test.ts creates an isolated database, applies NOTES_DDL, boots vite dev on a free port, waits for /api/health, then checks POST /api/notes (201) and GET /api/notes. |
| Manual | By hand | okm push and the curl calls above against vite dev, including both 400s, and the 500s with DATABASE_URL unset. |
| Not covered | The production build output, 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.