Frameworks

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

RuntimeNode.js 22.18 or newer. Bun installs packages and runs scripts.
Not coveredEdge and worker targets. Use Cloudflare Workers with Neon there.
Driverokmodel/pg/postgresjs + postgres
Exampleexamples/tanstack-start

Install

Terminal
bun add okmodel postgres @tanstack/react-start @tanstack/react-router nitro react react-dom
bun add -D vite @vitejs/plugin-react @tanstack/router-cli @types/node @types/react @types/react-dom typescript

Start 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:

vite.config.ts
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.

src/db.ts
/**
 * 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();
}
src/schema.ts
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

SettingWhereNotes
DATABASE_URLEnvironment of vite dev and of the built serverA direct TCP URL such as postgres://user:pass@host:5432/app.
maxconnect optionUp to 10 connections per process when omitted. Pool.
sslconnect optionForwarded to postgres.js for hosted databases. Pool.
preparedconnect optionLeave 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.

src/routes/api/notes.ts
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/okm

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

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:

Terminal
bunx okm push
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.

Terminal
bunx okm generate init
bunx okm migrate apply

Start 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.

Terminal
bunx vite dev --port 3105

Call 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/notes

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

RequestStatusBody
GET /api/health200{"ok":true}
POST /api/notes with {"title":"hello"}201{"id":"1","title":"hello"}
GET /api/notes200Up 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 body400{"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.ts

Deploy 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:

Terminal
bunx okm migrate apply

A 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 until bun run generate-routes regenerates src/routeTree.gen.ts.
SymptomCauseFix
Error: DATABASE_URL is required, and 500 on every routesrc/db.ts ran with no DATABASE_URLSet the variable where the server runs.
OKM1520 The database is behind the app on the first queryThe app was deployed before okm migrate applyApply the migrations, then start the app.
OKM1101 find() needs a limit.A read with no limitPass limit, as the example route does.
OkmError: relation "example_notes" does not exist (SQLSTATE 42P01)The table was never created on this databaseRun 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 URLPoint okm at the direct host.

What is tested

EvidenceWhereWhat runs
CIexamples / frameworks jobtanstack-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.
ManualBy handokm push and the curl calls above against vite dev, including both 400s, and the 500s with DATABASE_URL unset.
Not coveredThe 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.