kastor0.1.0
Get started

GUIDE 23Nuxt

kastor with Nuxt

Nuxt runs your server code in Nitro, its server engine. kastor lives in server/utils, your API lives in server/api, and pages read through useFetch, so the database and its credentials never reach the browser.

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:

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 files
Terminal
npm install kastor-orm pg
npx kastor-orm init --db postgres

Move 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.

nuxt.config.ts
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.

server/utils/db.ts
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 = db
server/plugins/db-close.ts
import { 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.

server/api/posts.get.ts
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),
  })
})
server/api/posts.post.ts
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
  }
})
server/api/posts/[id].get.ts
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.

pages/posts.vue
<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:

.github/workflows/deploy.yml
- run: npm ci
- run: npx nuxi typecheck
- run: npx kastor migrate deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
- run: npx nuxi build

On 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/utils and nothing under pages or components imports it.
  • Production sets NUXT_DATABASE_URL for the app and DATABASE_URL for 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 max of 1 to 5.
  • CI runs npx nuxi typecheck, which checks every query against the schema, before migrate deploy.

Pitfalls

  • Importing from components. Never import server/utils/db.ts from a .vue file 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; bigint values fail, so convert t.bigId() keys with String(id).
  • Pool size on serverless. Each function instance has its own pool; keep max low and use a pooler URL.
  • Two env names. The app reads NUXT_DATABASE_URL through runtimeConfig while the CLI reads DATABASE_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().

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.