Frameworks

Next.js

A typed Postgres client for Next.js App Router route handlers on the Node.js runtime, with the example's files, a migrate step, and what CI proves.

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

RuntimeThe Node.js runtime of Next.js (Node.js 22.18 or newer). Bun installs packages and runs scripts.
Not supportedThe Edge runtime.
Driverokmodel/pg/postgresjs + postgres
Exampleexamples/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

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

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

src/db.ts
/**
 * 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.

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

SettingWhereNotes
DATABASE_URLEnvironment of next dev, next build, and next startA 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.
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.

okm reads its own target from okmodel.config.ts, not from connect. 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.

app/api/notes/route.ts
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.

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!,
});

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:

Terminal
bunx okm push
target default
applied push-1

For a database you will deploy, write the first migration and apply it instead:

Terminal
bunx okm generate init
bunx okm migrate apply

okm generate init writes migrations/0001_init.sql and its catalog file. Commit both (Migrate commands). 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.

Terminal
bunx next dev --port 3104
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.

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

Terminal
bunx okm migrate apply

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

Terminal
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). A migration that only adds things lets the old version keep running while the release rolls out (Migrations in CI, 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).
SymptomCauseFix
Error: DATABASE_URL is required during next build, or a 500 from /api/notessrc/db.ts ran with no DATABASE_URLSet the variable where the build and the server run.
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.
500 and 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.
OKM1850 on migrateProtected target and a contract or unclassified stepReview the plan, then pass --allow-protected.

What is tested

EvidenceWhereWhat runs
CIexamples / frameworks jobnextjs.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.
ManualBy handokm 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 coverednext 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.