# Next.js

You get one `db` module that App Router route handlers import, a route that reads and writes a table, and the steps to create that table on an empty database. Everything runs on the Node.js runtime.

## Runtime and driver

| | |
| --- | --- |
| Runtime | The Node.js runtime of Next.js (Node.js 22.18 or newer). Bun installs packages and runs scripts. |
| Not supported | The Edge runtime. |
| Driver | `okmodel/pg/postgresjs` + `postgres` |
| Example | `examples/nextjs` |

A module-level `connect(...)` suits the Node.js runtime, where one long-lived process serves many requests and keeps one pool. The Edge runtime has no TCP socket for postgres.js, so every route that imports `src/db.ts` sets `export const runtime = "nodejs"`, and no Edge route or middleware may import it.

## Install

```sh
bun add okmodel postgres next react react-dom
bun add -D @types/node
```

Keep the driver and the package out of the Next.js bundle. The example does it in `next.config.ts`:

```ts title="next.config.ts" no-check="imports the NextConfig type from next, which is installed in examples/nextjs"
import type { NextConfig } from "next";

const config: NextConfig = {
  // Keep the example on the Node.js runtime (Fluid compute / Vercel Node).
  serverExternalPackages: ["postgres", "okmodel"],
  outputFileTracingRoot: import.meta.dirname,
};

export default config;
```

## `db` module and lifecycle

There is one client per Node.js process: every route handler shares the pool that `src/db.ts` opens. 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 Next.js example.
 *
 * Use the Node.js runtime only (`export const runtime = "nodejs"` on routes).
 * The Edge runtime is not supported here: postgres.js needs TCP.
 */

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 Node process. */
export const db = connect(url, { schema: app });

/** Closes the process client. */
export async function closeDb(): Promise<void> {
  await db.close();
}
```

`src/schema.ts` types the client. The table is named `example_notes`, so the client property is `db.example_notes`.

```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] });
```

No route calls `closeDb()`; the pool ends with the process. Call it from your own shutdown code or from a script that imports `src/db.ts`.

## Connection config

| Setting | Where | Notes |
| --- | --- | --- |
| `DATABASE_URL` | Environment of `next dev`, `next build`, and `next start` | A direct TCP URL such as `postgres://user:pass@host:5432/app`. `next build` imports the route module to collect page data, so it needs the variable set, but the database does not have to be reachable. |
| `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). |

`okm` reads its own target from `okmodel.config.ts`, not from `connect`. [Config](/docs/reference/config).

## One route that reads and writes

`GET` returns the oldest 50 notes. `POST` takes `{ "title": "..." }` and returns the new row with status 201. The example also has `app/api/health/route.ts`, which answers `{ "ok": true }` without importing `db`: it is the readiness check the test waits on, and the one to point a load balancer at.

`POST` has no authentication and no body size limit. Add both before you copy it.

```ts title="app/api/notes/route.ts" no-check="imports next/server, which is installed in examples/nextjs"
import { NextResponse } from "next/server";

import { db } from "../../../src/db";

export const runtime = "nodejs";

export async function GET() {
  const rows = await db.example_notes.find({
    orderBy: { id: "asc" },
    limit: 50,
  });
  return NextResponse.json(rows);
}

export async function POST(request: Request) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.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 NextResponse.json({ error: "title is required" }, { status: 400 });
  }
  const row = await db.example_notes.insert({ title });
  return NextResponse.json(row, { status: 201 });
}
```

## Dev and test

Start Postgres, point `DATABASE_URL` at it, create the table, then run Next.js. 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!,
});
```

Then create the table. `okm push` plans the schema against the database and applies it without writing a SQL file, so use it for 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 instead:

```sh
bunx okm generate init
bunx okm migrate apply
```

`okm generate init` writes `migrations/0001_init.sql` and its catalog file. Commit both ([Migrate commands](/docs/reference/cli/migrate)). After a later change to `src/schema.ts`, run `bunx okm push` again on a dev database, or `bunx okm generate <name>` then `bunx okm migrate apply`.

Start Next.js in the same shell. If `DATABASE_URL` is not exported there, `/api/health` still answers 200, but `/api/notes` answers 500 and the terminal prints `⨯ Error: DATABASE_URL is required`.

```sh
bunx next dev --port 3104
```

```sh
curl -i http://localhost:3104/api/health
curl -i -X POST http://localhost:3104/api/notes -H 'content-type: application/json' -d '{"title":"hello"}'
curl -i http://localhost:3104/api/notes
```

These responses come from that flow, run against `next 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/nextjs
cd examples/nextjs && REQUIRE_DOCKER=1 bun test ./nextjs.test.ts
```

`bun run build` runs at the repository root because the example links `okmodel` to that build. Run the test from the example directory, because the root test config skips `examples/`.

## Deploy and migrate-on-deploy

Build with `bunx next build` and start with `bunx next start` on a Node.js host. Set `DATABASE_URL` for the build and for the running server.

### On Vercel (Node.js runtime)

There is no separate Vercel example. Point a Vercel project at `examples/nextjs`, or copy its `app/`, `src/`, and `next.config.ts`. Use `bun install` and `bunx next build` if the project does not pick them up from the lockfile. Set `DATABASE_URL` for every environment that builds or runs the app; a preview deployment that shares the production URL reads and writes production data.

Apply migrations before the new deployment takes traffic. Prefer a release job with TCP to the database, run before the deploy step, so a failed migration stops the release:

```sh
bunx okm migrate apply
```

Alternatively, put migrate in the Vercel build command for that environment:

```sh
bunx okm migrate apply && bunx next build
```

With that command, keep Preview and Production on separate databases, or a preview build migrates production. Commit `okmodel.config.ts` and `migrations/`. A protected target needs `--allow-protected` for steps that remove or rewrite things ([Protected targets](/docs/operate/protected-targets-and-roles)). 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

- Under `next dev`, Fast Refresh can remount `src/db.ts` and open another pool each time. Idle connections on the old pools close after the default idle timeout.
- The route returns at most 50 rows. To read past them, page through the rest ([Read rows](/docs/queries/read-rows)).

| Symptom | Cause | Fix |
| --- | --- | --- |
| `Error: DATABASE_URL is required` during `next build`, or a 500 from `/api/notes` | `src/db.ts` ran with no `DATABASE_URL` | Set the variable where the build and the server run. |
| [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. |
| 500 and `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. |
| [OKM1850](/docs/reference/errors#okm1850) on migrate | Protected target and a contract or unclassified step | Review the plan, then pass `--allow-protected`. |

## What is tested

| Evidence | Where | What runs |
| --- | --- | --- |
| CI | `examples / frameworks` job | `nextjs.test.ts` creates an isolated database, applies `NOTES_DDL`, runs `next build` and `next start`, waits for `/api/health`, then checks `POST /api/notes` (201), `GET /api/notes`, and both 400s. |
| Manual | By hand | `okm push` applies `push-1` on an empty database; `okm generate init` then `okm migrate apply` applies `0001_init`. `next build` fails with `DATABASE_URL is required` when the variable is unset and builds when it points at an unreachable database. `next dev` and `next start` returned the responses in the table above. |
| Not covered | | `next dev` in CI, a Vercel deploy, and the Edge runtime. |

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)
