Project structure
Nuxt separates the Vue app from the server engine, Nitro. Database code belongs entirely in the server folder, which Nuxt never ships to the browser. That gives kastor a natural home in a Nuxt project:
nuxt.config.ts runtimeConfig with databaseUrl
server/
db/schema.ts tables and relations
utils/db.ts the client, auto imported in server routes
api/posts.get.ts GET /api/posts
api/posts.post.ts POST /api/posts
api/posts/[id].get.ts GET /api/posts/:id
plugins/db-close.ts closes the pool when Nitro shuts down
pages/posts.vue reads through useFetch
kastor.config.ts used by the CLI
kastor/migrations/ SQL filesnpm install kastor-orm pg
npx kastor-orm init --db postgresMove the starter schema to server/db/schema.ts and set schema: './server/db/schema.ts' in kastor.config.ts, so the app and the CLI read the same file.
Secrets through runtimeConfig
Nuxt reads runtime configuration from the environment at startup. A key declared at the top level of runtimeConfig is server only and is filled from a variable named NUXT_ plus the key in upper snake case.
export default defineNuxtConfig({
runtimeConfig: {
// Filled from NUXT_DATABASE_URL at runtime. Not under "public", so it stays on the server.
databaseUrl: '',
},
})The kastor CLI does not read runtimeConfig; it reads DATABASE_URL from the environment or from .env next to the config. Set both names in .env during development, or point the config at NUXT_DATABASE_URL.
The client in server/utils
Nitro reloads the server bundle when you edit server files in development. Keep the client on globalThis outside production so each reload reuses the same pool instead of opening another one.
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: useRuntimeConfig().databaseUrl, 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 = dbimport { db } from '../utils/db'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('close', async () => {
await db.$disconnect()
})
})Typed server routes
Files in server/api become routes, and the HTTP method comes from the file name. Exports from server/utils are auto imported, so db is available without an import; the explicit import below works too and keeps editors happy outside Nuxt's generated types.
import { db } from '../utils/db'
export default defineEventHandler(async (event) => {
const { q, take } = getQuery(event)
return db.posts.findMany({
where: {
published: true,
...(q ? { title: { contains: String(q), mode: 'insensitive' as const } } : {}),
},
include: { author: { select: { name: true } }, tags: true },
orderBy: { id: 'desc' },
take: Math.min(Number(take ?? 20), 100),
})
})import { isKastorError } from 'kastor-orm'
import { db } from '../utils/db'
export default defineEventHandler(async (event) => {
const body = await readBody<{ title: string; authorId: number; tags?: string[] }>(event)
try {
const post = await db.posts.create({
data: {
title: body.title,
authorId: body.authorId,
tags: { connectOrCreate: (body.tags ?? []).map((name) => ({ where: { name }, create: { name } })) },
},
include: { tags: true },
})
setResponseStatus(event, 201)
return post
} catch (e) {
if (isKastorError(e) && e.code === 'FOREIGN_KEY_VIOLATION') {
throw createError({ statusCode: 422, statusMessage: 'Unknown author' })
}
throw e
}
})import { db } from '../../utils/db'
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
const post = await db.posts.findUnique({ where: { id }, include: { author: true, tags: true } })
if (!post) throw createError({ statusCode: 404, statusMessage: 'Post not found' })
return post
})Pages read through useFetch. During server side rendering the call goes straight to the route handler without HTTP, and the response type is inferred from the handler's return value, which kastor typed from the schema.
<script setup lang="ts">
const { data: posts } = await useFetch('/api/posts', { query: { take: 10 } })
</script>
<template>
<ul>
<li v-for="p in posts" :key="p.id">{{ p.title }} by {{ p.author.name }}</li>
</ul>
</template>Migrations in CI and deploy
Nitro builds a standalone server in .output. Run migrations before that server starts serving the new code. In CI:
- run: npm ci
- run: npx nuxi typecheck
- run: npx kastor migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- run: npx nuxi buildOn a single server, running npx kastor migrate deploy && node .output/server/index.mjs in the start script also works. On serverless presets (Vercel, Netlify), use the CI step and the pooled connection string for the app. On the Cloudflare preset, read event.context.cloudflare.env.DB in a handler, create the client with d1() per request, and apply migrations with wrangler as the Workers guide describes.
Transactions and shared queries
Server routes that write several rows should wrap them in db.$transaction(async (tx) => ...); the callback commits when it resolves and rolls back when it throws, including when you throw createError. Queries used by several routes fit naturally in more files under server/utils, which Nitro also auto imports, so a function like publishedFeed() is available everywhere on the server.
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:
- The client lives in
server/utilsand nothing underpagesorcomponentsimports it. - Production sets
NUXT_DATABASE_URLfor the app andDATABASE_URLfor the migration step. - The Nitro close hook disconnects the pool, so rolling restarts do not leave connections behind.
- Serverless presets use a pooled URL and a pool
maxof 1 to 5. - CI runs
npx nuxi typecheck, which checks every query against the schema, beforemigrate deploy.
Pitfalls
- Importing from components. Never import
server/utils/db.tsfrom a.vuefile or a composable. Go through a server route. - Bigint and Date over JSON. Server routes serialize responses as JSON. Dates become strings on the client;
bigintvalues fail, so convertt.bigId()keys withString(id). - Pool size on serverless. Each function instance has its own pool; keep
maxlow and use a pooler URL. - Two env names. The app reads
NUXT_DATABASE_URLthrough runtimeConfig while the CLI readsDATABASE_URL. Set both in every environment where you run migrations.
The SvelteKit guide shows the same ideas with load functions, and the API reference lists every query option used here.
Questions people ask
Q1Where should the database client live in Nuxt?
In server/utils/db.ts. Nitro auto imports everything exported from server/utils into server routes, and nothing in the server folder is bundled for the browser.
Q2How do I pass the database URL in Nuxt?
Declare runtimeConfig: { databaseUrl: '' } in nuxt.config.ts and set NUXT_DATABASE_URL in the environment. Read it with useRuntimeConfig().databaseUrl on the server. Keys outside public never reach the client.
Q3Can Nuxt pages query kastor directly?
No. Vue components can run in the browser. Expose a server route and call it with useFetch or $fetch, which runs on the server during SSR and over HTTP in the browser.
Q4Does it work on Cloudflare with Nuxt?
Yes. With the Cloudflare preset, read the D1 binding from event.context.cloudflare.env.DB and build the client per request with d1().