Use Drizzle with Postgres in SvelteKit
Aug 19, 2026 SvelteKit
Drizzle is a TypeScript ORM. You describe tables in TypeScript. Queries against those tables get their types from the schema. Below we connect it to PostgreSQL in a SvelteKit app and build a notes page.
The query has to run on the server. You want adapter-node, adapter-vercel, or anything else that runs server code. adapter-static has no server, so nothing can open a Postgres connection. load and form actions already run on the server. Put the query there. That is the full-stack part.
The Svelte CLI can scaffold the connection files:
npx sv add drizzle="database:postgresql+client:postgres.js+docker:yes" If you already ran that, skip to the schema and the page. Otherwise create the files below by hand. You need a SvelteKit app first. npx sv create will give you one.
We will end up with these files:
drizzle.config.ts
src/lib/server/db/
├── index.ts
└── schema.ts
src/routes/notes/
├── +page.svelte
└── +page.server.ts You also need a DATABASE_URL in .env and a Postgres process that URL can reach.
Table of Contents
Step 1: run Postgres
If you do not already have Postgres, Docker is the least work. Create compose.yml in the project root:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- '5432:5432'
volumes:
- dbdata:/var/lib/postgresql/data
volumes:
dbdata: docker compose up -d db The URL for this container is:
postgres://app:app@localhost:5432/app A hosted database works the same way. Paste its connection string instead. Neon and Supabase both give you a Postgres URL.
Step 2: packages and env
Install Drizzle ORM, the postgres.js driver, and Drizzle Kit. The Svelte CLI picks postgres.js for Postgres. Add dotenv so Drizzle Kit can read .env:
npm install drizzle-orm postgres
npm install -D drizzle-kit dotenv Put the URL in .env at the project root. Vite already loads this file for SvelteKit. Drizzle Kit does not use Vite, so it needs dotenv.
DATABASE_URL=postgres://app:app@localhost:5432/app Keep .env out of git. Copy the same key into .env.example with an empty value so the next person knows it exists.
Add these next to your existing package.json scripts:
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio" Step 3: the schema
Create src/lib/server/db/schema.ts. Drizzle Kit turns this file into SQL. Your queries import the same objects. Rename a column and the next db.select() does not compile. You find out before a request hits Postgres.
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const notes = pgTable('notes', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: text().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
}); pgTable is the Postgres helper. SQLite uses sqliteTable. That setup is a separate post. generatedAlwaysAsIdentity() is a Postgres identity column. Inserts omit id. The database fills it in. The TypeScript key is createdAt. The database column is created_at because of the string argument.
Anything under $lib/server is server only. If a .svelte file imports this module, the build fails. The schema and the connection string next to it stay out of the client bundle.
Step 4: drizzle-kit config
Create drizzle.config.ts at the project root. This file is for Drizzle Kit. It is not part of the SvelteKit app, so it cannot use $env/static/private. That alias only exists inside Vite.
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is not set');
}
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL
}
}); out is where SQL migration files go if you later run drizzle-kit generate. For this tutorial we will push the schema straight to the database.
Step 5: the database client
Create src/lib/server/db/index.ts. Read the URL through $env/static/private so client code cannot import it.
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { DATABASE_URL } from '$env/static/private';
import * as schema from './schema';
if (!DATABASE_URL) {
throw new Error('DATABASE_URL is not set');
}
const client = postgres(DATABASE_URL);
export const db = drizzle({ client, schema }); Create the client once at module load. Do not create it inside load or an action. postgres.js already pools connections. A new postgres() on every request leaks them.
Passing schema into drizzle() is optional for db.select() and db.insert(). You need it later if you use db.query.
Step 6: create the table
With Postgres running and DATABASE_URL set:
npm run db:push Drizzle Kit diffs schema.ts against the live database and applies the difference. Fine on a laptop. Do not do this to production. Generate SQL with drizzle-kit generate and apply it with drizzle-kit migrate so every environment runs the same files.
npm run db:studio opens Drizzle Studio in the browser if you want to inspect rows without writing SQL.
Step 7: a notes page
Create src/routes/notes/+page.server.ts:
import { fail } from '@sveltejs/kit';
import { desc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { notes } from '$lib/server/db/schema';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const rows = await db.select().from(notes).orderBy(desc(notes.createdAt));
return { notes: rows };
};
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const title = String(data.get('title') ?? '').trim();
if (!title) {
return fail(400, { message: 'Title is required' });
}
await db.insert(notes).values({ title });
return { message: 'Saved.' };
},
delete: async ({ request }) => {
const data = await request.formData();
const id = Number(data.get('id'));
if (!Number.isInteger(id) || id < 1) {
return fail(400, { message: 'Invalid note' });
}
await db.delete(notes).where(eq(notes.id, id));
}
}; load selects every note, newest first. The default action inserts. delete removes one row. eq and desc come from drizzle-orm, not from the driver. After a successful action, SvelteKit reruns load. The list updates. You do not query again in the action.
Create src/routes/notes/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<h1>Notes</h1>
<form method="POST" use:enhance>
<label>
Title
<input name="title" required />
</label>
<button type="submit">Add</button>
</form>
{#if form?.message}
<p>{form.message}</p>
{/if}
<ul>
{#each data.notes as note (note.id)}
<li>
{note.title}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={note.id} />
<button type="submit">Delete</button>
</form>
</li>
{/each}
</ul> action="?/delete" hits the named action. The add form has no action, so it hits default. use:enhance turns the submit into a fetch. The forms still work with JavaScript off.
Open /notes, type a title, and submit. You should see the row under the form. Delete should remove it.
Things that trip people up
Do not import $lib/server/db from a .svelte file or from +page.ts. +page.ts runs in the browser on navigation. Put database code in +page.server.ts, +server.ts, hooks.server.ts, or other $lib/server modules.
If you put DATABASE_URL in $env/static/public or any PUBLIC_ variable, Vite compiles it into the client. Private env vars belong in $env/static/private.
Calling postgres(DATABASE_URL) inside load opens a new pool on every request. Use the module-level client in index.ts.
Pushing schema from a laptop onto production leaves you with no migration history. Use generate and migrate once more than one environment needs the same change.
If you prerender /notes, load runs at build time against whatever database the build machine can see. The HTML matches that snapshot. A live list needs a server at request time.
This postgres.js client on a serverless host opens TCP connections from every instance. A traffic spike can exhaust Postgres. On Vercel, use a pooler. Supabase’s transaction pooler needs prepare: false on the postgres.js client. Or use the Neon serverless driver. adapter-node on a long-running process is fine with the client above.
Anyone who can load /notes can POST to it. If notes belong to a user, check locals.user in both load and the actions the same way HttpOnly cookie auth does.
What to add next
The cookie auth example stores sessions in a Map. That empties on restart. Replace it with a sessions table. Columns are id, user_id, and expires_at. Use the same db client.
If you upload files or send them to S3, store the object key and owner in a table instead of a JSON file. Serving those files behind auth is then a select plus the same session check.