SvelteKit with Prisma and SQLite
- Published
- Updated
On this page
- What changed in Prisma 7
- Setting up SvelteKit
- Installing Prisma and the driver adapter
- Configuring the schema and prisma.config.ts
- Generating the client
- The Prisma client singleton
- db push vs migrate dev: when to use which
- Running the first migration
- CRUD helpers
- Building the blog UI
- Common pitfalls
- Wrapping up
If you want type-safe database access in a SvelteKit app, Prisma is the shortest path from zero to a working ORM. This post walks through wiring SvelteKit 2 to a SQLite database with Prisma 7, end to end: schema, driver adapter, migrations, and a small blog built on form actions.
Prisma 7 reshaped the setup. The query engine is gone, driver adapters are mandatory, and configuration moved to a new prisma.config.ts file. If you're coming from an older tutorial, the steps below won't match what you remember — they're worth relearning.
If you'd rather use Drizzle, see SvelteKit with SQLite and Drizzle for the same build with a different ORM.
What changed in Prisma 7
A quick orientation for returning readers:
Driver adapters are the only way to connect. SQLite now goes through
@prisma/adapter-better-sqlite3(or@prisma/adapter-libsqlon Bun).prisma.config.tsreplaces most of what used to live inschema.prisma. Theurlmoves out of the datasource block and into the config file.The new generator is
prisma-client(notprisma-client-js). It requires an explicitoutputpath. You importPrismaClientfrom there, not from@prisma/client..envno longer auto-loads. Addimport "dotenv/config"at the top ofprisma.config.ts.Commands don't auto-generate anymore.
prisma db pushandprisma migrate devstopped runningprisma generateandprisma db seedimplicitly. Call them yourself.
The moving parts just have different names. Define a schema, generate a client, run migrations, query from server code. The mental model hasn't changed.
Setting up SvelteKit
Scaffold a new SvelteKit project with TypeScript:
bunx sv create sveltekit-prisma-sqlite
cd sveltekit-prisma-sqlite
bun install
Pick the minimal template with TypeScript enabled. The rest of this post uses plain HTML and a little Tailwind. If you want shadcn-svelte components instead, their install guide plugs in cleanly.
Installing Prisma and the driver adapter
Three packages: the CLI, the client, and the SQLite adapter.
bun add -D prisma
bun add @prisma/client @prisma/adapter-better-sqlite3
On Bun, swap @prisma/adapter-better-sqlite3 for @prisma/adapter-libsql. better-sqlite3's native bindings don't run on Bun.
Initialize Prisma:
bunx prisma init --datasource-provider sqlite
This creates prisma/schema.prisma and a root .env with DATABASE_URL="file:./dev.db". It does not create prisma.config.ts. You'll add that next.
Configuring the schema and prisma.config.ts
Open prisma/schema.prisma. The default is close to what you want, but a few things change for Prisma 7:
generator client {
provider = "prisma-client"
output = "../src/lib/server/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model Post {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String @unique
title String
content String?
}
Notice what's missing: no url in the datasource block. That moves to prisma.config.ts at the project root:
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
prisma.config.ts is where Prisma reads credentials, migration paths, and other configuration. schema.prisma is now just the model layer. Keep DATABASE_URL="file:./dev.db" in .env.
Generating the client
bunx prisma generate
This writes the typed client to src/lib/server/generated/prisma. Add that folder to .gitignore. It's regenerated on every schema change, and committing it churns diffs for no benefit:
# .gitignore
src/lib/server/generated
Regenerate after every schema.prisma edit.
The Prisma client singleton
SvelteKit reloads server modules during dev, which can spawn new PrismaClient instances every save. That's fine in production, but in development it leaks connections until SQLite complains. The standard fix is a singleton on globalThis:
// src/lib/server/prisma.ts
import { DATABASE_URL } from "$env/static/private";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "./generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
const adapter = new PrismaBetterSqlite3({ url: DATABASE_URL });
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
Two things:
The import comes from your generated output path, not
@prisma/client. If TypeScript complains, runbunx prisma generate.The adapter wraps
better-sqlite3. Prisma hands it the connection URL and the adapter handles the actual I/O.
$env/static/private inlines DATABASE_URL at build time. If you deploy a prebuilt artifact that reads env at runtime (a container, for instance), switch to $env/dynamic/private.
db push vs migrate dev: when to use which
Prisma gives you two ways to turn schema changes into database changes, and the difference matters.
prisma db push applies your schema directly to the database. No migration files, no history, no audit trail. It's a one-way sync: the DB now matches the schema.
bunx prisma db push
Use it when:
You're prototyping and iterating on the schema every few minutes.
The database is ephemeral (a local SQLite file you can delete).
You haven't committed any production migrations yet.
prisma migrate dev generates a timestamped SQL file in prisma/migrations/, applies it, and records it in a _prisma_migrations table.
bunx prisma migrate dev --name initial_migration
Use it when:
A teammate needs to reproduce the schema evolution.
You're about to deploy, or you've deployed once already.
The schema is stable enough that every change is worth naming.
Reach for db push during the first hour of a project. Switch to migrate dev the moment a second person touches the database. Mixing the two mid-project gets you into an inconsistent state fast — once migrate dev has run, stop using push.
Prisma 7 removed the implicit generate step from both commands, so follow up with bunx prisma generate when the schema changed.
For production, bunx prisma migrate deploy applies pending migrations without generating new ones.
Running the first migration
With the Post model in place, create the initial migration:
bunx prisma migrate dev --name initial_migration
bunx prisma generate
You'll see a new file under prisma/migrations/<timestamp>_initial_migration/migration.sql:
-- CreateTable
CREATE TABLE "Post" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT
);
-- CreateIndex
CREATE UNIQUE INDEX "Post_slug_key" ON "Post"("slug");
That file goes in git. It's the canonical record of how the schema came to exist.
CRUD helpers
Wrap the queries you'll reuse in a small helper module. Keeping them under $lib/server means SvelteKit's build won't let them leak into the browser bundle.
// src/lib/server/post.ts
import type { Prisma } from "$lib/server/generated/prisma/client";
import { prisma } from "./prisma";
export async function createNewPost(post: Prisma.PostCreateInput) {
return prisma.post.create({ data: post });
}
export async function getPostBySlug(slug: string) {
return prisma.post.findUnique({ where: { slug } });
}
export async function getAllPosts() {
return prisma.post.findMany({
orderBy: { createdAt: "desc" },
});
}
The generated client gives you full types for inputs, filters, and return values with zero manual effort. Prisma.PostCreateInput enforces the shape of data against your schema. Rename the slug column and this function stops compiling until you update it.
Building the blog UI
A list view, a detail view, and a form to create a new post.
Listing and creating posts
// src/routes/blog/+page.server.ts
import { createNewPost, getAllPosts } from "$lib/server/post";
import { fail, redirect } from "@sveltejs/kit";
export const load = async () => {
const posts = await getAllPosts();
return { posts };
};
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const title = String(formData.get("title") ?? "").trim();
const slug = String(formData.get("slug") ?? "").trim();
const content = String(formData.get("content") ?? "");
if (!title || !slug) {
return fail(400, { message: "Title and slug are required." });
}
const post = await createNewPost({ title, slug, content });
redirect(302, `/blog/${post.slug}`);
},
};
The page uses use:enhance for progressive enhancement and Svelte 5's $props() rune:
<!-- src/routes/blog/+page.svelte -->
<script lang="ts">
import { enhance } from "$app/forms";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Blog</title>
</svelte:head>
<div class="max-w-5xl mx-auto px-4 py-16 space-y-8">
<h1 class="text-3xl font-bold">Blog</h1>
{#if data.posts.length}
<ul class="space-y-2">
{#each data.posts as post (post.id)}
<li>
<a class="text-2xl font-bold" href="/blog/{post.slug}">{post.title}</a>
</li>
{/each}
</ul>
{:else}
<p>No posts yet.</p>
{/if}
<form method="post" use:enhance class="space-y-4">
<label class="block">
<span class="block text-sm font-medium">Title</span>
<input name="title" required class="border rounded px-2 py-1 w-full" />
</label>
<label class="block">
<span class="block text-sm font-medium">Slug</span>
<input name="slug" required class="border rounded px-2 py-1 w-full" />
</label>
<label class="block">
<span class="block text-sm font-medium">Content</span>
<textarea name="content" rows={8} class="border rounded px-2 py-1 w-full"></textarea>
</label>
<button type="submit" class="px-4 py-2 bg-black text-white rounded">Create post</button>
</form>
</div>
The detail page
// src/routes/blog/[slug]/+page.server.ts
import { getPostBySlug } from "$lib/server/post";
import { error } from "@sveltejs/kit";
export const load = async ({ params }) => {
const post = await getPostBySlug(params.slug);
if (!post) error(404, "Post not found");
return { post };
};
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.post.title}</title>
</svelte:head>
<article class="max-w-3xl mx-auto px-4 py-16 space-y-4">
<h1 class="text-3xl font-bold">{data.post.title}</h1>
{#if data.post.content}
<div class="whitespace-pre-wrap">{data.post.content}</div>
{/if}
</article>
SvelteKit's dynamic route segment [slug] binds the URL part to params.slug. findUnique returns Post | null, so the if (!post) guard narrows the type for the rest of the function.
Common pitfalls
Forgetting to regenerate after a schema change
Edit schema.prisma, add a field, hit save, and your IDE still shows the old types. The generated client lives on disk until you rebuild it:
bunx prisma generate
Prisma 7 no longer runs this implicitly after db push or migrate dev, so it's on you. A common pattern is to wire it into your scripts:
// package.json
"scripts": {
"db:push": "prisma db push && prisma generate",
"db:migrate": "prisma migrate dev && prisma generate"
}
Importing PrismaClient from @prisma/client
That path is deprecated in Prisma 7. Import from the output directory declared in your generator block:
// Wrong
import { PrismaClient } from "@prisma/client";
// Right
import { PrismaClient } from "$lib/server/generated/prisma/client";
If the import resolves but types look stale, regenerate.
DATABASE_URL undefined at build time
$env/static/private reads .env at build time. If you run the build where .env isn't present (CI, a Docker layer), the variable is empty and the adapter refuses to connect. Either commit a .env.example and copy it in CI, or switch to $env/dynamic/private for deploy-time resolution.
Wrapping up
The Prisma 7 + SvelteKit 2 + SQLite stack is about as low-friction as typed server code gets. A schema file, a config file, a client singleton, and you're querying with full types. Migrations are one command. The adapter pattern is slightly more ceremony than the old direct connection, but it paid for itself the day the Rust engine went away.
If you're deciding between ORMs, the Drizzle walkthrough is the natural next read. To see this stack inside a full app, that's exactly what we build in the Full Stack SvelteKit course.
Happy coding!