Database access in SvelteKit: what developers should know
By day 38 of working with SvelteKit, you've likely moved past static content and client-side logic into building features that require persistent data. The moment you need to store user profiles, transaction records, or product inventory, database access becomes central to your application. Getting this right—understanding where to run queries, how to structure your code, and what gotchas exist—separates a prototype from something you can deploy with confidence.
The server-only boundary
The golden rule of database access in SvelteKit is simple: never import database clients or query code into your client-side bundles. This means your database credentials, connection strings, and raw SQL never leave your server. SvelteKit enforces this through its file structure.
Any file inside src/routes with a name ending in +server.ts or +page.server.ts runs only on the server. Similarly, anything you place in src/lib/server is server-only. Files outside these directories—your +page.svelte components, utilities in src/lib without the server prefix—will be included in your client bundle if you import from them.
Here's what this looks like in practice:
// src/lib/server/db.ts - Safe to use database client
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);
// src/routes/api/users/+server.ts - Server endpoint, safe to import db
import { db } from '$lib/server/db';
import { json } from '@sveltejs/kit';
export async function GET() {
const users = await db.select().from(usersTable);
return json(users);
}
// src/routes/profile/+page.server.ts - Server load function, safe to import db
import { db } from '$lib/server/db';
export async function load() {
const profile = await db.select().from(profilesTable).limit(1);
return { profile };
}
// src/lib/utils.ts - Dangerous! Imported in client code
// Don't do this:
// import { db } from '$lib/server/db'; // This bundle includes DB client!
SvelteKit's build process will throw an error if you try to import server-only code into client code, but only when you explicitly break the boundary. The file structure is your primary defense.
Choosing between Drizzle and Prisma ORMs
For most SvelteKit projects, you'll reach for either Drizzle or Prisma. Both are excellent; the choice depends on your team's preferences and specific constraints.
Prisma is mature and widely adopted. Its schema is straightforward, migrations are robust, and the generated client is type-safe. Prisma manages a connection pool for you. It's particularly good if you want conventions and hand-holding:
// schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
userId Int
user User @relation(fields: [userId], references: [id])
}
// src/lib/server/db.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;
// src/routes/posts/+page.server.ts
import db from '$lib/server/db';
export async function load() {
const posts = await db.post.findMany({
include: { user: true },
});
return { posts };
}
Drizzle is newer but rapidly gaining traction. It's lightweight, gives you more control, and its query builder feels closer to SQL. For serverless environments where connection reuse matters, Drizzle can be a cleaner fit:
// src/lib/server/schema.ts
import { pgTable, serial, text, varchar } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email').unique(),
name: varchar('name'),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title'),
content: text('content'),
userId: serial('user_id').references(() => users.id),
});
// src/lib/server/db.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);
// src/routes/posts/+page.server.ts
import { db } from '$lib/server/db';
import { posts, users } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
export async function load() {
const allPosts = await db.select().from(posts).leftJoin(users, eq(posts.userId, users.id));
return { allPosts };
}
Both approaches are valid. Prisma wins on ease of use and ecosystem maturity. Drizzle wins on control and bundle size. Most teams pick Prisma for traditional backends and Drizzle for serverless-first architectures.
Connection pooling for serverless
If you deploy to Vercel, Netlify, or another serverless platform, every function invocation is a cold start. Creating a fresh database connection for each request is catastrophically slow. This is where connection pooling becomes essential.
A connection pool maintains a set of reusable database connections across multiple requests. When your function needs a query, it borrows a connection from the pool; when done, the connection goes back for the next request.
For Prisma, this is handled by PrismaClient's built-in pooling. For Drizzle, you control it more directly:
// src/lib/server/db.ts - Drizzle with connection pooling
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(process.env.DATABASE_URL!, {
max: 1, // For serverless, use 1 connection per isolate
});
export const db = drizzle(client);
The max: 1 setting is counterintuitive but correct for serverless. Each Lambda/Edge Function isolate runs in its own process, so a single connection per process is actually a pool across warm requests. If you set max: 10 in a serverless context, you're creating unnecessary overhead and wasting resources.
For a traditional server (Node.js on EC2, Railway, or Render), you can safely use larger pools:
// src/lib/server/db.ts - Traditional server with larger pool
const client = postgres(process.env.DATABASE_URL!, {
max: 10, // Multiple connections for handling concurrent requests
});
Additionally, consider using a connection proxy like PgBouncer or Neon's serverless driver to add a layer of pooling outside your application. This is especially important if you're spawning many concurrent Lambda functions.
Keep queries in $lib/server
As your application grows, you'll accumulate dozens of database queries. Where should they live? The answer: in $lib/server, organized by domain.
Create query functions that encapsulate your database logic. This keeps your route handlers clean and makes queries reusable:
// src/lib/server/queries/users.ts
import { db } from '../db';
import { users } from '../schema';
import { eq } from 'drizzle-orm';
export async function getUserById(id: number) {
return await db.select().from(users).where(eq(users.id, id)).limit(1);
}
export async function getUserByEmail(email: string) {
return await db.select().from(users).where(eq(users.email, email)).limit(1);
}
export async function listAllUsers() {
return await db.select().from(users);
}
// src/routes/api/users/[id]/+server.ts
import { json, error } from '@sveltejs/kit';
import { getUserById } from '$lib/server/queries/users';
export async function GET({ params }) {
const user = await getUserById(parseInt(params.id));
if (!user) {
return error(404, 'User not found');
}
return json(user);
}
// src/routes/dashboard/+page.server.ts
import { listAllUsers } from '$lib/server/queries/users';
export async function load() {
const users = await listAllUsers();
return { users };
}
This pattern has several benefits. Your route handlers remain readable and focused. Your queries are testable in isolation. If a query changes, you update one place. And you avoid scattering SQL (or ORM calls) across your codebase.
Common mistakes
Mistake 1: Creating a new database client on every request. This defeats connection pooling. Always create your database client once at module load time and export it:
// Wrong - new connection per request
export async function GET() {
const client = new PrismaClient(); // Bad!
const data = await client.user.findMany();
return json(data);
}
// Right - single client instance
const prisma = new PrismaClient();
export async function GET() {
const data = await prisma.user.findMany();
return json(data);
}
Mistake 2: Blocking on database queries in your route handlers without considering timeouts. Serverless functions have execution limits. A slow query can cause timeout errors. Use timeouts explicitly in your query code or wrap long-running operations in background jobs.
Mistake 3: Leaking database code into the client bundle. Always check your route structure. If you're not using +page.server.ts or placing code in $lib/server, you're likely bundling your database client for clients to download.
Mistake 4: Not handling connection errors gracefully. Database connectivity issues happen. Wrap your queries in try-catch blocks and return meaningful error responses:
export async function load() {
try {
const data = await db.select().from(someTable);
return { data };
} catch (err) {
console.error('Database query failed:', err);
return { data: null, error: 'Failed to load data' };
}
}
Key takeaways
- Database code belongs exclusively in server files (
+server.ts,+page.server.ts) or in$lib/server. Never import database clients into client components. - Choose between Prisma (convention-heavy, batteries-included) or Drizzle (lightweight, explicit control) based on your team's preferences and deployment target.
- For serverless, use connection pooling with
max: 1per isolate. For traditional servers, use larger pools. - Organize queries as reusable functions in
$lib/server/queriesby domain. Keep route handlers lean and focused. - Always create your database client once at module initialization and reuse it. Handle connection errors gracefully with try-catch blocks.






Comments
Loading comments…