kastor0.1.0
Get started

GUIDE 24SvelteKit

kastor with SvelteKit

SvelteKit already draws a hard line between server and browser code: anything under $lib/server can never be imported by the client. Put the kastor client there, read in load functions, write in form actions, and the types flow from your schema to the page props.

Project structure

In SvelteKit, every route can have a server file next to its page: +page.server.ts for load functions and form actions, +server.ts for plain HTTP endpoints. Both run only on the server, and both import the client from $lib/server.

Project
src/lib/server/
  schema.ts                       tables and relations
  db.ts                           the client
src/routes/posts/
  +page.server.ts                 load and actions
  +page.svelte                    the page
src/routes/api/posts/+server.ts   JSON endpoint
kastor.config.ts                  schema: './src/lib/server/schema.ts'
kastor/migrations/
.env                              DATABASE_URL
Terminal
npm install kastor-orm pg
npx kastor-orm init --db postgres
npx kastor migrate dev --name init

Move the starter schema to src/lib/server/schema.ts and update the schema path in kastor.config.ts. SvelteKit and the CLI both read .env, so one DATABASE_URL serves both in development.

The client in $lib/server

Vite reloads server modules during development. Keep one client on globalThis so reloads do not open new pools, and read the URL from $env/dynamic/private so the value comes from the runtime environment rather than being baked into the build.

src/lib/server/db.ts
import pg from 'pg'
import { env } from '$env/dynamic/private'
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: 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

Reading in load functions

Whatever a server load function returns becomes the page's data prop, typed from the return value. Because kastor infers the row types, the page knows that post.author.name is string | null without a single hand written interface.

src/routes/posts/+page.server.ts
import { fail } from '@sveltejs/kit'
import { isKastorError } from 'kastor-orm'
import { db } from '$lib/server/db'

export const load = async ({ url }: { url: URL }) => {
  const tag = 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,
  })
  return { posts, tag }
}

export const actions = {
  create: async ({ request }: { request: Request }) => {
    const form = await request.formData()
    const title = String(form.get('title') ?? '').trim()
    if (title.length < 3) return fail(400, { title, message: 'Title is too short.' })
    try {
      await db.posts.create({ data: { title, authorId: Number(form.get('authorId')) } })
    } catch (e) {
      if (isKastorError(e) && e.code === 'FOREIGN_KEY_VIOLATION') return fail(422, { title, message: 'Unknown author.' })
      throw e
    }
    return { created: true }
  },
  publish: async ({ request }: { request: Request }) => {
    const id = Number((await request.formData()).get('id'))
    await db.posts.update({ where: { id }, data: { published: true } })
  },
}

Form actions and the page

Form actions take plain HTML forms, so the page works before JavaScript loads. use:enhance upgrades them to fetch requests without a full reload. The action above returns fail() with the same shape for both validation errors and database errors, which keeps the page simple:

src/routes/posts/+page.svelte
<script lang="ts">
  import { enhance } from '$app/forms'
  let { data, form } = $props()
</script>

<form method="POST" action="?/create" use:enhance>
  <input name="title" value={form?.title ?? ''} />
  <input name="authorId" type="number" value="1" />
  <button>Create</button>
  {#if form?.message}<p class="error">{form.message}</p>{/if}
</form>

<ul>
  {#each data.posts as post (post.id)}
    <li>{post.title} by {post.author.name ?? 'anonymous'}</li>
  {/each}
</ul>

For clients other than your pages, add a +server.ts endpoint that returns JSON:

src/routes/api/posts/+server.ts
import { json } from '@sveltejs/kit'
import { db } from '$lib/server/db'

export const GET = async ({ url }: { url: URL }) => {
  const page = await db.posts.findPage({
    where: { published: true },
    orderBy: { id: 'desc' },
    first: 20,
    after: url.searchParams.get('after'),
  })
  return json(page)
}

Adapters, environment variables and migrations

With adapter-node, the build in build/ is a long running server: one pool per process, created on the first import. Run migrations before starting it, either in CI or in the start script:

package.json
{
  "scripts": {
    "build": "vite build",
    "start": "kastor migrate deploy && node build",
    "check": "svelte-kit sync && svelte-check"
  }
}

With adapter-vercel or adapter-netlify, routes run as functions. Keep max small, use a pooled connection string, and run npx kastor migrate deploy as a CI step with the direct URL before the deployment is promoted. With adapter-cloudflare, use the binding instead of a URL: kastor({ adapter: d1(platform.env.DB), schema, relations }) inside the load function or action, and apply migrations with wrangler as the Workers guide explains.

Hooks, locals and transactions

Some apps want the client on event.locals so every load function and action reads it from the same place. A handle hook in src/hooks.server.ts can set event.locals.db = db, and the App.Locals interface types it. That is optional on Node, where importing $lib/server/db is enough, but it is handy on Cloudflare, where the hook builds the client from event.platform.env.DB once per request.

Actions that write several rows should use db.$transaction(async (tx) => ...). Validate the form first and return fail() before the transaction starts, so no connection is held while the user corrects the input.

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 $lib/server, and only .server files import it.
  • The URL comes from $env/dynamic/private, not the static module.
  • Actions validate input and return fail() before touching the database.
  • Function based adapters use a pooled URL and a small pool.
  • CI runs svelte-check and then npx kastor migrate deploy before the release.

Pitfalls

  • Universal load functions. +page.ts (without .server) also runs in the browser, and SvelteKit will refuse the $lib/server import there. Query only in +page.server.ts, +layout.server.ts and +server.ts.
  • Serialization. Load data travels to the browser through devalue, which handles Date and bigint. Responses from json() do not handle bigint, so convert those keys with String(id).
  • Static env. $env/static/private inlines the value at build time; a build made with a staging URL then talks to staging forever. Prefer the dynamic module for connection strings.
  • Prerendering. Pages marked prerender = true query the database at build time. Make sure CI can reach it, or leave those pages dynamic.

Questions people ask

Q1Where should the database client go in SvelteKit?

In src/lib/server/db.ts. SvelteKit refuses to bundle anything from $lib/server into client code, so the driver and the connection string can never leak.

Q2Should I use $env/static/private or $env/dynamic/private?

Use $env/dynamic/private for the database URL. Static values are inlined at build time, while dynamic ones are read when the server starts, which suits secrets that differ per environment.

Q3How do I return a validation error from a form action?

Return fail(400, { message }) from @sveltejs/kit. Catch KastorError codes such as UNIQUE_VIOLATION and turn them into the same shape.

Q4Does kastor work with adapter-cloudflare?

Yes. Read the D1 binding from platform.env.DB in the load function or action and create the client with d1() per request.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.