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
| 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
bun add okmodel postgres next react react-dombun add -D @types/nodeKeep the driver and the package out of the Next.js bundle. The example does it in 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.
/**
* 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.
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. |
ssl | connect option | Forwarded to postgres.js for hosted databases. Pool. |
prepared | connect option | Leave 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.
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/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!,
});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:
bunx okm pushtarget default
applied push-1For a database you will deploy, write the first migration and apply it instead:
bunx okm generate initbunx okm migrate applyokm 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.
bunx next dev --port 3104curl -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/notesThese 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:
bun run build
bun run db:up
bun install --cwd examples/nextjs
cd examples/nextjs && REQUIRE_DOCKER=1 bun test ./nextjs.test.tsbun 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:
bunx okm migrate applyAlternatively, put migrate in the Vercel build command for that environment:
bunx okm migrate apply && bunx next buildWith 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 remountsrc/db.tsand 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).
| 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 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. |
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 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.