kastor0.1.0
Get started

GUIDE 05Next.js and Postgres

Next.js with PostgreSQL: a type-safe ORM setup for the App Router

This guide sets up kastor as the Next.js Postgres ORM for an App Router project: a single client that survives hot reloads, queries in server components and server actions, serverless Postgres providers, and migrations that run on deploy.

Install kastor and the Postgres driver

kastor ships no database drivers. For PostgreSQL you install pg yourself and hand a pool to the postgres adapter. There is no generate step and no binary engine, so nothing extra runs in next build: types come straight from your TypeScript schema.

Terminal
npm install kastor-orm pg server-only
npm install -D @types/pg
npx kastor-orm init --db postgres

init --db postgres writes kastor.config.ts, a starter src/db/schema.ts with users and posts, a src/db/seed.ts and a .env with DATABASE_URL, and adds .env to .gitignore. server-only is a small package from the Next.js ecosystem that makes the build fail if a module marked with it ends up in a client bundle. It is the cheapest insurance against leaking your database module, and we will use it below. If you have not set up the framework side yet, the Next.js setup notes cover project layout.

One db module, reused across hot reloads

In development, Next.js re-evaluates modules every time you save a file. If your database module creates a new pg.Pool at the top level, every reload opens a fresh pool while the old ones keep their connections until they time out. After a few minutes of editing you hit the server's connection limit.

The fix is the globalThis pattern: build the client once, keep it on globalThis outside production, and reuse it on the next evaluation. In production modules are evaluated once per process, so the cache is not needed there.

src/db/index.ts
import 'server-only'
import pg from 'pg'
import { kastor, one, many } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { users, posts } from './schema'

function createDb() {
  const pool = new pg.Pool({
    connectionString: process.env.DATABASE_URL,
    max: 10,
  })
  return kastor({
    adapter: postgres(pool),
    schema: { users, posts },
    relations: ({ users, posts }) => ({
      users: { posts: many(posts) },
      posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
    }),
  })
}

const globalForDb = globalThis as unknown as { db?: ReturnType<typeof createDb> }

export const db = globalForDb.db ?? createDb()

if (process.env.NODE_ENV !== 'production') globalForDb.db = db

The schema lives in src/db/schema.ts and is plain TypeScript built with table() and t. Every query result is typed from it, including included relations, so there is nothing to regenerate when you add a column.

Querying in server components

With the App Router, pages and layouts are server components by default. They can be async and await a query directly, which keeps data loading next to the markup that uses it:

app/posts/page.tsx
import { db } from '@/db'

export default async function PostsPage() {
  const posts = await db.posts.findMany({
    where: { published: true },
    include: { author: { select: { name: true } } },
    take: 20,
  })

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {post.title} by {post.author?.name ?? 'anonymous'}
        </li>
      ))}
    </ul>
  )
}

The include loads each post's author in the same round trip, so rendering a list never turns into one query per row. The N+1 guide explains why that matters and how to check it. Queries are lazy, so building one does nothing until it is awaited; you can call .toSQL() on it to inspect the statements, or open the playground to see the exact SQL for each database.

Static or dynamic rendering

One App Router detail surprises many people: a page that only reads from the database, with no cookies, headers or search params, can be prerendered at build time. The query then runs during next build, needs a reachable database there, and the page shows build time data until it is regenerated. Decide explicitly. Use export const revalidate = 60 to regenerate at most once a minute, or export const dynamic = 'force-dynamic' to query on every request.

Server actions and route handlers

Writes belong in server actions. A form posts to the action, the action writes with kastor and revalidates the page. Errors come back as KastorError with a code, so you can turn a unique violation into a friendly message instead of a 500:

app/posts/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { isKastorError } from 'kastor-orm'
import { db } from '@/db'

export async function createPost(_prev: { error: string | null }, formData: FormData) {
  const title = String(formData.get('title') ?? '').trim()
  if (!title) return { error: 'Title is required' }

  try {
    await db.posts.create({
      data: { title, authorId: Number(formData.get('authorId')) },
    })
  } catch (e) {
    if (isKastorError(e) && e.code === 'FOREIGN_KEY_VIOLATION') {
      return { error: 'That author does not exist' }
    }
    throw e
  }

  revalidatePath('/posts')
  return { error: null }
}

This signature fits React's useActionState hook, which passes the previous state first and shows the returned error in the form. In a real app, take the author id from the session rather than the form. For JSON endpoints that other clients call, use a route handler. The same db import works there:

app/api/posts/route.ts
import { db } from '@/db'

export const runtime = 'nodejs'

export async function GET(request: Request) {
  const q = new URL(request.url).searchParams.get('q') ?? ''
  const posts = await db.posts.findMany({
    where: { published: true, title: { contains: q, mode: 'insensitive' } },
    select: { id: true, title: true },
    take: 50,
  })
  return Response.json(posts)
}

The runtime = 'nodejs' line matters: pg opens TCP sockets, which the Node.js runtime supports. Keep database routes on it unless your driver is built for other runtimes.

Serverless Postgres providers and connection pooling

Serverless deployments change the connection math. Each function instance has its own pool, and a traffic spike can start dozens of instances. Ten connections per instance times fifty instances is more than many Postgres plans allow. Four habits keep you safe:

  • Use the provider's pooled connection string for the app. Supabase offers a pooler connection string next to the direct one, and Neon offers a pooled host. A pooler multiplexes many client connections onto a few server connections.
  • Keep max small in serverless, often between 1 and 5 per instance, since one instance rarely handles many requests at once.
  • Use the direct connection string for migrations. Schema changes are better run over a direct session than through a transaction pooler.
  • Do not disconnect per request. Calling db.$disconnect() at the end of a handler throws the pool away and forces the next request to reconnect. Let the instance keep it.

Because the postgres adapter takes any pg compatible pool, provider choice is a one line change. With Supabase you pass a regular pg.Pool built from its connection string. With Neon you can use the same pg.Pool, or the Pool exported by @neondatabase/serverless. Vercel Postgres is powered by Neon, so the same options apply. The rest of your code, including every query, stays untouched.

Environment variables and migrations on deploy

Put connection strings in .env.local for development and in your host's environment settings for production. Never prefix them with NEXT_PUBLIC_: that prefix inlines the value into browser bundles.

Next.js loads .env.local for the app, but the kastor CLI runs outside Next.js. On its own, the CLI reads only a .env file next to kastor.config.ts (the one kastor init writes), and never overrides variables that are already set. The simplest way to give the CLI the same variables is loadEnvConfig from @next/env, which ships with Next.js, at the top of your config. Because the CLI loads .env before it runs the config, a variable defined in both files keeps the .env value for the CLI, so define each connection string in only one of them. The config below also prefers a direct URL for migrations when one is set:

kastor.config.ts
import { loadEnvConfig } from '@next/env'
import { defineConfig } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import pg from 'pg'

loadEnvConfig(process.cwd())

export default defineConfig({
  schema: './src/db/schema.ts',
  adapter: () =>
    postgres(new pg.Pool({ connectionString: process.env.DIRECT_URL ?? process.env.DATABASE_URL })),
  migrations: './kastor/migrations',
  seed: './src/db/seed.ts',
})

Locally, change the schema and run npx kastor migrate dev --name add_posts, then commit the generated SQL with the code. On deploy, run npx kastor migrate deploy once, before the new version takes traffic. You can do it in a CI step or prefix your build command so it runs before next build. Watch out for preview deployments that share the production database: a preview build that runs migrations will change production. Give previews their own database or skip migrations there. The migrations guide covers safe destructive changes.

Keep the db module out of client components

A file with 'use client' at the top, and everything it imports, is bundled for the browser. If it imports @/db, Next.js would try to ship the pg driver and your connection logic to the client. The import 'server-only' line in the db module turns that mistake into a build error instead of a security problem.

The working pattern is simple:

  • Load data in a server component and pass plain objects to client components as props.
  • For writes from interactive UI, call a server action. The client receives a reference to the action, never the database code.
  • Share types, not modules: import type the table and use InferSelect<typeof posts>. Type only imports are erased at build time, so they are safe anywhere.

When you need to browse the data while building pages, npx kastor studio opens a local web UI with your tables and an ER diagram.

Questions people ask

Q1What is the best way to connect Next.js to PostgreSQL?

Create one database module that builds a single pg pool and client, cache it on globalThis in development, and import it only from server code: server components, server actions and route handlers.

Q2Why do I get too many connections in Next.js development?

Hot reload re-evaluates your modules, and each evaluation can open a new pool. Store the client on globalThis outside production so the same pool is reused across reloads.

Q3Can I use kastor with Neon, Supabase or Vercel Postgres?

Yes. The postgres adapter takes a pg compatible pool, so you pass a pg.Pool built from the provider's connection string, or the pool from @neondatabase/serverless. Use the provider's pooled connection string for the app.

Q4Can I query the database from a client component?

No. Client components run in the browser, which must never hold database credentials. Query in a server component and pass the data as props, or call a server action from the client.

Q5When should Next.js migrations run on deploy?

Run npx kastor migrate deploy once per deploy, before the new version serves traffic, either as a CI step or at the start of your build command. Never run migrate dev against production.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.