SvelteKit with SQLite and Drizzle
By Justin Ahinon.
Last updated
Connecting SvelteKit to a SQLite database with Drizzle ORM is the fastest way to ship a typed, full-stack Svelte app without standing up a separate database server. This guide walks through the setup end to end on Svelte 5 and SvelteKit 2: picking a driver, defining a schema, running migrations, writing CRUD queries, and loading related rows.
Everything here also underpins the database work in the Full Stack SvelteKit course, so consider this the canonical reference post.
Why SQLite
SQLite used to get dismissed as a hobby database. It isn't. It's a single file, zero-config, and fast enough to power the majority of apps you'll ever build.
For production, the two usual concerns (durability and replication) are solved by projects like Litestream (streams the SQLite file to S3-compatible storage) and Turso (a managed libsql service with edge replicas). You keep the single-file simplicity and get the durability, backups, and replication you would otherwise be paying a managed Postgres for.
Why Drizzle
Drizzle is a TypeScript-first ORM with a SQL-like query builder. Your schema lives in TypeScript, and every query is typed from that source of truth. No codegen step, no schema drift between your database and your types.
Its companion CLI, Drizzle Kit, handles migrations, introspection, and the local Drizzle Studio UI.
Picking a driver: better-sqlite3 vs libsql
Drizzle supports two SQLite drivers. Pick based on where the database lives, not on performance.
better-sqlite3: native bindings, synchronous API, local file only. Simplest thing that works when you self-host and never plan to move off-box.libsql(@libsql/client): async API, reads a local file or a remote Turso database with the same code. Start here if you want the option to move to Turso later.
We'll cover both below; pick one and skip the other.
Setting up a SvelteKit project
Start with the sv CLI. It replaced create-svelte and is the official scaffolder:
bunx sv create sqlite-drizzle
cd sqlite-drizzle
Pick the Node adapter for any SvelteKit app that hits a server-side database. The auto adapter may default to a serverless target with no native SQLite support. Pick the SvelteKit template, TypeScript, and the Node adapter when the scaffolder prompts.
Installing Drizzle
Install the ORM, the CLI, and the driver you picked.
# Option A: better-sqlite3
bun add drizzle-orm better-sqlite3
bun add -D drizzle-kit @types/better-sqlite3 tsx
# Option B: libsql
bun add drizzle-orm @libsql/client
bun add -D drizzle-kit tsx
We also want nanoid for primary keys (the old oslo/crypto approach is deprecated):
bun add nanoid
The database client
SvelteKit exposes server-only environment variables via $env/static/private, so there is no reason to pull in dotenv anymore. Set the DB path once:
echo "DB_URL=file:src/lib/server/db/sqlite.db" >> .env
echo "src/lib/server/db/sqlite.db*" >> .gitignore
The file: prefix is required for libsql. better-sqlite3 accepts a raw path too, but keeping the prefix means you can switch drivers without touching the env file.
Create the client. Use whichever block matches the driver you installed.
// src/lib/server/db/client.ts (better-sqlite3)
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { DB_URL } from '$env/static/private';
const sqlite = new Database(DB_URL.replace(/^file:/, ''));
export const db = drizzle(sqlite);
// src/lib/server/db/client.ts (libsql)
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { DB_URL } from '$env/static/private';
const client = createClient({ url: DB_URL });
export const db = drizzle(client);
The only user-facing difference from here on is that every query on the libsql client returns a promise, so you await results instead of getting them synchronously.
Drizzle config
The current idiom is defineConfig from drizzle-kit:
// src/lib/server/db/drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './src/lib/server/db/migrations',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DB_URL!
}
});
Drizzle Kit reads this file outside the SvelteKit runtime, so process.env works here even though your app code should use the $env/* modules.
Defining the schema
Let's model a simple blog: posts with an author. Primary keys are opaque 15-char nanoid strings, not auto-increment integers, because you almost always want an ID that is safe to expose in URLs.
// src/lib/server/db/schema.ts
import { relations } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { nanoid } from 'nanoid';
const timestamps = {
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date())
};
export const authors = sqliteTable('authors', {
id: text('id').primaryKey().$defaultFn(() => nanoid(15)),
name: text('name').notNull(),
email: text('email').notNull().unique(),
...timestamps
});
export const posts = sqliteTable('posts', {
id: text('id').primaryKey().$defaultFn(() => nanoid(15)),
authorId: text('author_id')
.notNull()
.references(() => authors.id),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
body: text('body'),
tags: text('tags', { mode: 'json' }).$type<string[]>(),
...timestamps
});
export const authorsRelations = relations(authors, ({ many }) => ({
posts: many(posts)
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(authors, {
fields: [posts.authorId],
references: [authors.id]
})
}));
A few things worth flagging:
mode: 'timestamp'stores dates as Unix seconds. Use'timestamp_ms'if you need millisecond precision.mode: 'json'on a text column lets Drizzle serialize and deserialize arrays or objects for you. That is howtagsstays a real string array on the TypeScript side..references(() => authors.id)writes a real foreign key into the migration. SQLite only enforces foreign keys whenPRAGMA foreign_keys = ONis set. Both better-sqlite3 and libsql enable it by default.
Migration scripts
Wire up the commands you will actually use:
// package.json
"scripts": {
"db:generate": "drizzle-kit generate --config=src/lib/server/db/drizzle.config.ts",
"db:migrate": "drizzle-kit migrate --config=src/lib/server/db/drizzle.config.ts",
"db:push": "drizzle-kit push --config=src/lib/server/db/drizzle.config.ts",
"db:studio": "drizzle-kit studio --config=src/lib/server/db/drizzle.config.ts"
}
db:generatecreates SQL migration files from your schema diff.db:migrateapplies them.db:pushskips the files and applies the diff directly. Use this locally for fast iteration, never in production.db:studioopens a browser UI for browsing data.
Now generate and apply:
bun run db:generate
bun run db:migrate
The generated SQL ends up in src/lib/server/db/migrations/ and looks like plain DDL. You can eyeball it before applying:
CREATE TABLE `authors` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
CREATE UNIQUE INDEX `authors_email_unique` ON `authors` (`email`);
CREATE TABLE `posts` (
`id` text PRIMARY KEY NOT NULL,
`author_id` text NOT NULL,
`title` text NOT NULL,
`slug` text NOT NULL,
`body` text,
`tags` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`author_id`) REFERENCES `authors`(`id`)
);
CREATE UNIQUE INDEX `posts_slug_unique` ON `posts` (`slug`);
Basic CRUD
Drizzle's query builder is where the SQL-like ergonomics show up. Every method is typed against your schema, so eq(posts.id, id) errors at compile time if you typo the column.
// src/lib/server/db/posts.ts
import { eq } from 'drizzle-orm';
import { db } from './client';
import { posts } from './schema';
export const getPostById = (id: string) =>
db.select().from(posts).where(eq(posts.id, id)).get();
export const getPostBySlug = (slug: string) =>
db.select().from(posts).where(eq(posts.slug, slug)).get();
export const createPost = (data: typeof posts.$inferInsert) =>
db.insert(posts).values(data).returning().get();
export const updatePost = (id: string, data: Partial<typeof posts.$inferInsert>) =>
db.update(posts).set(data).where(eq(posts.id, id)).run();
export const deletePost = (id: string) =>
db.delete(posts).where(eq(posts.id, id)).run();
Notes:
.get()returns the first row orundefined..all()returns an array. Both are better-sqlite3-only sync methods. On libsql, drop them andawaitthe query.typeof posts.$inferInsertand$inferSelectgive you typed row shapes without writing a single interface..returning()afterinsert/updatereturns the affected rows, which is useful when the row has server-generated IDs or timestamps.
Querying with relations
For any non-trivial read, reach for the relational query API instead of hand-rolling joins. Pass the schema when you instantiate the client:
// src/lib/server/db/client.ts
import * as schema from './schema';
// ...
export const db = drizzle(sqlite, { schema });
Then you can load a post with its author in one call:
const post = db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.slug, slug),
with: { author: true }
});
Or list every author with their posts:
const directory = db.query.authors.findMany({
with: {
posts: {
columns: { id: true, title: true, slug: true }
}
}
});
This is the cleanest read path in Drizzle. You get nested, typed results without manually shaping a join. Under the hood it still compiles to a single SQL query.
Drizzle v1 (currently in beta) ships a rewritten relations API (RQB v2) with a defineRelations helper. The v1-style relations() calls shown above are still the stable recommendation today. When v1 lands, the migration guide is the place to start.
Using it from a SvelteKit route
In Svelte 5 / SvelteKit 2, a server load function looks like this:
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/client';
export const load = async ({ params }) => {
const post = db.query.posts.findFirst({
where: (p, { eq }) => eq(p.slug, params.slug),
with: { author: true }
});
if (!post) throw error(404, 'Not found');
return { post };
};
The load function runs on the server, so you can call your Drizzle client directly from it. No API route, no fetch, no second place to keep types in sync. The page consumes the data with runes:
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
let { data } = $props();
</script>
<article>
<h1>{data.post.title}</h1>
<p>By {data.post.author.name}</p>
{@html data.post.body}
</article>
Note let { data } = $props() instead of export let data. That is the Svelte 5 rune syntax.
Common pitfalls
Running drizzle-kit push in production. push skips migration files and mutates the database directly. Great for local iteration, dangerous once real data exists. Stick to generate plus migrate in any shared environment.
Forgetting file: on libsql URLs. libsql expects file:local.db for a local database. A bare ./local.db silently falls back to an in-memory database on some versions. Your data disappears between runs and you spend an hour confused.
Committing the SQLite file. The .gitignore line matters. A 50 MB dev DB in your repo history is hard to get out.
better-sqlite3 platform mismatch. It is a native module. If you build on macOS and deploy to Linux, you need to rebuild on the target platform, or use libsql and skip the problem entirely.
Wrapping up
SQLite plus Drizzle is the shortest path from "new SvelteKit project" to typed database queries in production. Pick better-sqlite3 for a local-only app, libsql when you want the option to move to Turso later, and lean on the relational query API the moment your schema grows beyond a single table.
Next step, once the database layer is in place: wire up email and password authentication against the authors table. Or skip passwords entirely with Google sign-in. For the full build (routing, forms, deploy, and the rest), there is the Full Stack SvelteKit course.
Happy coding!