# TanStack Start

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](/docs/frameworks/workers) with Neon there. |
| Driver | `okmodel/pg/postgresjs` + `postgres` |
| Example | `examples/tanstack-start` |

## Install

```sh
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:

```ts title="vite.config.ts" no-check="imports vite and the TanStack Start plugins, which are installed in examples/tanstack-start"
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.

```ts title="src/db.ts" no-check="imports ./schema without an extension, which the example's bundler resolves and the docs check does not"
/**
 * 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();
}
```

```ts title="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

| 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](/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 /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.

```ts title="src/routes/api/notes.ts" no-check="imports @tanstack/react-router, which is installed in examples/tanstack-start"
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.

```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
```

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

```sh
bunx vite dev --port 3105
```

Call it from another terminal:

```sh
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.

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

```sh
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:

```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

- 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](/docs/queries/read-rows)).
- A new route file under `src/routes/` is not registered until `bun run generate-routes` regenerates `src/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](/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`. |
| `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.

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