Project structure
The Next.js App Router splits code into server and client modules. Everything that touches the database belongs on the server side, and kastor makes that easy to keep straight: one schema file, one client module, and server code that imports the client. A typical layout looks like this:
app/
posts/page.tsx server component that reads
posts/actions.ts server actions that write
api/posts/route.ts route handler (Node.js runtime)
api/edge/route.ts route handler (edge runtime)
src/db/
schema.ts tables and relations
index.ts the client, created once
kastor.config.ts used by the CLI
kastor/migrations/ SQL files written by migrate dev
.env.local DATABASE_URL, never NEXT_PUBLIC_npm install kastor-orm pg server-only
npx kastor-orm init --db postgres
npx kastor migrate dev --name initinit writes kastor.config.ts and a starter schema in src/db/schema.ts. The CLI reads .env next to the config, while Next.js reads .env.local; either keep the variable in both or point the CLI at the right file in CI. Keep the name without the NEXT_PUBLIC_ prefix so it never reaches the browser bundle.
One client, reused across hot reloads
In development, Next.js re-evaluates modules on every change. A module that creates a pool at import time would open a new pool each time and slowly exhaust the database. Keep the client on globalThis outside production, and import server-only so a mistaken import from a client component fails the build instead of leaking credentials.
import 'server-only'
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './schema'
const create = () =>
kastor({
adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 })),
schema,
relations,
})
const g = globalThis as unknown as { db?: ReturnType<typeof create> }
export const db = g.db ?? create()
if (process.env.NODE_ENV !== 'production') g.db = db
export type Db = typeof dbIn production on serverless hosts, each function instance gets its own module scope and therefore its own pool. Keep max small and use your provider's pooled connection string (PgBouncer, Supavisor or the Neon pooler) so many instances share a few real connections.
Server components and server actions
Server components are async functions that run on the server, so they can await a query directly. The result is typed from the schema, including the included relations.
import { db } from '@/db'
import { publish } from './actions'
export default async function PostsPage() {
const posts = await db.posts.findMany({
where: { published: false },
include: { author: { select: { name: true } } },
orderBy: { id: 'desc' },
take: 20,
})
return (
<ul>
{posts.map((p) => (
<li key={p.id}>
{p.title} by {p.author.name ?? 'anonymous'}
<form action={publish}>
<input type="hidden" name="id" value={p.id} />
<button>Publish</button>
</form>
</li>
))}
</ul>
)
}Writes go in server actions. Put them in a file that starts with 'use server', validate the form input, then revalidate the pages that show the data. kastor errors carry a stable code, so turning a unique violation into a form message is one if.
'use server'
import { revalidatePath } from 'next/cache'
import { isKastorError } from 'kastor-orm'
import { db } from '@/db'
export async function publish(formData: FormData) {
const id = Number(formData.get('id'))
await db.posts.update({ where: { id }, data: { published: true } })
revalidatePath('/posts')
}
export async function createTag(formData: FormData) {
try {
await db.tags.create({ data: { name: String(formData.get('name')).trim() } })
revalidatePath('/tags')
return { ok: true }
} catch (e) {
if (isKastorError(e) && e.code === 'UNIQUE_VIOLATION') return { ok: false, error: 'That tag already exists.' }
throw e
}
}Route handlers
Route handlers expose HTTP endpoints for other clients. They run in the Node.js runtime by default, so they share the pooled client above.
import { db } from '@/db'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = await db.posts.findPage({
where: { published: true },
orderBy: { id: 'desc' },
first: 20,
after: searchParams.get('after'),
})
return Response.json(page)
}
export async function POST(request: Request) {
const body = (await request.json()) as { title: string; authorId: number }
const post = await db.posts.create({ data: { title: body.title, authorId: body.authorId } })
return Response.json(post, { status: 201 })
}The edge runtime: Neon, Turso or D1
Code that sets export const runtime = 'edge' runs without Node.js TCP sockets, so the pg pool above is not available there. kastor's core has no Node only imports, so the same schema works with a driver built for the edge. Create the client inside the request: edge isolates do not keep connections between requests the way a Node process does.
import { Pool } from '@neondatabase/serverless'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from '@/db/schema'
export const runtime = 'edge'
export async function GET() {
// Neon's Pool speaks the pg API over WebSockets, so the postgres adapter takes it.
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
try {
const db = kastor({ adapter: postgres(pool), schema, relations })
return Response.json(await db.posts.findMany({ where: { published: true }, take: 10 }))
} finally {
await pool.end()
}
}For Turso, use the web build of the libSQL client, which talks HTTP:
import { createClient } from '@libsql/client/web'
import { kastor } from 'kastor-orm'
import { libsql } from 'kastor-orm/libsql'
import { schema, relations } from './schema'
export const tursoDb = () =>
kastor({
adapter: libsql(createClient({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN })),
schema,
relations,
})When the whole app runs on Cloudflare through the OpenNext adapter, the D1 binding comes from the Cloudflare context instead of an environment variable. D1 runs batches instead of interactive transactions, so use the array form of $transaction there.
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { kastor } from 'kastor-orm'
import { d1 } from 'kastor-orm/d1'
import { schema, relations } from './schema'
export const d1Db = () => kastor({ adapter: d1(getCloudflareContext().env.DB), schema, relations })Environment variables and migrations on deploy
Keep two connection strings. The application uses the pooled one; migrations use the direct one, because poolers in transaction mode do not keep the session state a migration needs. On Vercel, set both in the project settings, then run migrations before the new build takes traffic. The simplest option is a CI step:
name: deploy
on: { push: { branches: [main] } }
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: npx tsc --noEmit
- run: npx kastor migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL_UNPOOLED }}If you prefer a single step, set the build command to npx kastor migrate deploy && next build. Either way there is no generate step: types come from src/db/schema.ts through the compiler, so next build type checks queries against the schema in the same commit.
Pitfalls
- Client components. Never import
@/dbfrom a file with'use client'. Pass data down as props from a server component, or call a server action. - Caching. Next.js may cache route handlers and pages. Queries through kastor are plain function calls, so use
revalidatePath,revalidateTagorexport const dynamic = 'force-dynamic'the way the Next.js docs describe for your version. - Serializing results. Data passed from server to client components must be serializable.
Dateworks,bigintdoes not: convertt.bigId()keys withString(id)first. - Edge pools. Do not keep a Neon
Poolin module scope on the edge; create it per request and end it, as above.
The Next.js with PostgreSQL guide goes deeper into managed Postgres providers, and the Prisma migration guide helps if the app used Prisma before.
Questions people ask
Q1Where should the kastor client live in a Next.js app?
In one server only module such as src/db/index.ts that starts with import 'server-only'. Server components, server actions and route handlers import it; client components never do.
Q2Why does Next.js open too many database connections in development?
Hot reload evaluates modules again, and each evaluation can create a new pool. Keep the client on globalThis outside production so every reload reuses the same pool.
Q3Can I use kastor in the Next.js edge runtime?
Yes, with a driver that works there: @neondatabase/serverless for Postgres over WebSockets, @libsql/client/web for Turso, or the D1 binding when the app runs on Cloudflare. Create the client per request in edge code.
Q4How do I run migrations for a Next.js app on Vercel?
Run npx kastor migrate deploy before the new build serves traffic, either as a CI step or by prefixing the build command. Use the direct, unpooled connection string for migrations.