Project structure and the adapter
Astro needs a server adapter before any page can query a database per request. With @astrojs/node in standalone mode, the build is a Node server with one long lived process; other adapters turn on-demand routes into functions.
npx astro add node
npm install kastor-orm pg
npx kastor-orm init --db postgres
npx kastor migrate dev --name initastro.config.mjs adapter: node(), output stays "static" or "server"
src/db/schema.ts tables and relations
src/lib/db.ts the client
src/pages/posts.astro on-demand page (prerender = false)
src/pages/api/posts.ts JSON endpoint
src/actions/index.ts Astro Actions for forms
kastor.config.ts
kastor/migrations/
.env DATABASE_URLimport { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
adapter: node({ mode: 'standalone' }),
})One client for the whole server
Astro's dev server reloads modules on change, so keep the client on globalThis during development. Server code can read process.env on Node based adapters; import.meta.env also works and is how Astro exposes .env values to server code.
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from '../db/schema'
const create = () =>
kastor({
adapter: postgres(new pg.Pool({ connectionString: import.meta.env.DATABASE_URL ?? 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 = dbQuerying in on-demand pages
The frontmatter of an .astro page runs on the server. With prerender = false it runs per request, so the page can read query parameters and always shows fresh rows.
---
import { db } from '../lib/db'
export const prerender = false
const tag = Astro.url.searchParams.get('tag')
const posts = await db.posts.findMany({
where: { published: true, ...(tag ? { tags: { some: { name: tag } } } : {}) },
include: { author: { select: { name: true } }, tags: true },
orderBy: { id: 'desc' },
take: 20,
})
---
<ul>
{posts.map((p) => (
<li>{p.title} by {p.author.name} {p.tags.map((t) => <span>#{t.name}</span>)}</li>
))}
</ul>Leaving out prerender = false on a page with a server adapter in the default static mode runs the query once, at build time. That is a legitimate choice for pages like a list of categories: no database round trip per visitor, at the cost of a rebuild when the data changes.
Endpoints and actions for writes
Files in src/pages/api that export HTTP method handlers become endpoints. They suit JSON clients:
import type { APIRoute } from 'astro'
import { isKastorError } from 'kastor-orm'
import { db } from '../../lib/db'
export const prerender = false
export const GET: APIRoute = async ({ url }) => {
const posts = await db.posts.findMany({ where: { published: true }, take: Number(url.searchParams.get('take') ?? 20) })
return Response.json(posts)
}
export const POST: APIRoute = async ({ request }) => {
const body = (await request.json()) as { title: string; authorId: number }
try {
return Response.json(await db.posts.create({ data: body }), { status: 201 })
} catch (e) {
if (isKastorError(e)) return Response.json({ error: e.message, hint: e.hint }, { status: 400 })
throw e
}
}For forms on your own pages, Astro Actions validate input and are called like functions. The handler runs on the server, so it can call kastor directly:
import { defineAction } from 'astro:actions'
import { z } from 'astro:schema'
import { db } from '../lib/db'
export const server = {
publishPost: defineAction({
accept: 'form',
input: z.object({ id: z.coerce.number() }),
handler: async ({ id }: { id: number }) => {
const post = await db.posts.update({ where: { id }, data: { published: true } })
return { id: post.id, published: post.published }
},
}),
}Environment variables and migrations on deploy
Set DATABASE_URL in the host's environment without a PUBLIC_ prefix. With the Node adapter, run migrations before starting the server:
npm run build
npx kastor migrate deploy
node ./dist/server/entry.mjsOn Vercel and Netlify, on-demand routes are functions: keep max small, use a pooled connection string, and run npx kastor migrate deploy in CI with the direct URL before the deployment goes live. If prerendered pages query the database, the build step itself needs DATABASE_URL and must run after migrations. On Cloudflare, build the client per request from Astro.locals.runtime.env.DB with d1().
Static, on demand, or both
Astro lets each page choose when it runs. A good rule for database backed pages is to prerender what is the same for every visitor and changes rarely, such as documentation or a category list, and render on demand what depends on the visitor or changes often, such as a feed, a search page or an account page. Both kinds can import the same src/lib/db.ts: prerendered pages call it once during the build, on demand pages on every request.
When a prerendered page queries kastor, the build needs the database: CI must have DATABASE_URL and must run migrations before astro build. When nothing is prerendered from the database, the build can run without any connection, which keeps preview deployments simple.
Production checklist
Before the first deploy, check these points. Each one prevents a problem that otherwise shows up only under real traffic, usually as exhausted connections, a leaked secret or a schema that does not match the code that is running:
- A server adapter is installed, and pages that query per request set
prerender = false. DATABASE_URLhas noPUBLIC_prefix.- Islands receive plain, serializable props, never the client.
- Builds that prerender database pages run after migrations and can reach the database.
- Serverless adapters use a pooled URL and a pool
maxof 1 to 5.
Pitfalls
- Client scripts. Code in
<script>tags and framework islands (React, Vue, Svelte components withclient:*) runs in the browser. Pass data to them as props from the frontmatter, never import the db module there. - Build time queries. A static page that queries kastor freezes the data at build time. That is easy to forget when an editor asks why a change does not show.
- Bigint in props. Props passed to islands are serialized; convert
bigintkeys withString(id). - Pool per function. Serverless adapters create one pool per instance, so use a pooler.
Questions people ask
Q1Can Astro query a database at build time?
Yes. Prerendered pages run their frontmatter during astro build, so a kastor query there runs once at build. That suits content that changes rarely; the build machine must reach the database.
Q2How do I make an Astro page query the database on every request?
Use a server adapter and export const prerender = false from the page, or set output: 'server' so every page renders on demand.
Q3Where should DATABASE_URL live in Astro?
In .env or the host's environment, without the PUBLIC_ prefix. Variables without that prefix are only available to server code, so they never reach the browser.
Q4Does kastor work with the Cloudflare adapter?
Yes. Read the D1 binding from Astro.locals.runtime.env.DB in pages, or context.locals.runtime.env.DB in endpoints, and create the client with d1() per request.