$lib/server: Svelte & SvelteKit best practices explained. Keep server-only code safe.
One of the quickest ways to leak database credentials, API keys, or user data in a SvelteKit application is to accidentally expose server-only logic to the browser. The $lib/server directory is SvelteKit's built-in solution to this problem—a designated area where you can confidently place sensitive code knowing that the framework will enforce strict boundaries between server and client at build time.
Code here cannot be imported by client code
The fundamental rule of $lib/server is simple: anything inside this directory can only be imported and executed on the server. If client code tries to import from $lib/server, SvelteKit will refuse to build your application. This isn't a runtime check; it's a build-time guarantee.
Consider a scenario where we have utility functions for interacting with our database and sending emails. In a typical application, you might organize these in a utils folder and accidentally reference them from a client component:
// src/lib/server/db.ts
import { DB } from 'some-db-library';
export const db = new DB({
connectionString: process.env.DATABASE_URL,
});
export async function getUserById(id: string) {
return db.query('SELECT * FROM users WHERE id = ?', [id]);
}
Without the $lib/server directory structure, a developer might write this in a +page.svelte:
// src/routes/profile/+page.svelte (CLIENT CODE)
import { getUserById } from '$lib/db'; // WRONG - accidentally importing server code
onMount(async () => {
const user = await getUserById(userId);
});
With $lib/server, that same import attempt becomes a build error:
// src/routes/profile/+page.svelte (CLIENT CODE)
import { getUserById } from '$lib/server/db'; // Build error: Cannot import from $lib/server in client code
This fail-fast behavior prevents entire categories of security vulnerabilities. The boundary is enforced strictly: browser bundles simply cannot contain code from $lib/server.
SvelteKit enforces it at build time
The enforcement happens during the build process, not at runtime. When you run npm run build, SvelteKit's compiler analyzes all import statements and dependency chains. If it detects that a client component or page is importing anything from $lib/server, the build fails immediately with a clear error message.
Let's walk through a real example. Suppose we have an authentication module:
// src/lib/server/auth.ts
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET;
export function generateToken(userId: string) {
return jwt.sign({ userId }, SECRET, { expiresIn: '7d' });
}
export function verifyToken(token: string) {
return jwt.verify(token, SECRET);
}
If a form action in a server-side load function properly uses this:
// src/routes/login/+page.server.ts
import { generateToken } from '$lib/server/auth';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const username = data.get('username');
// ... validate username/password
const token = generateToken(userId);
return { success: true, token };
}
};
This works fine because +page.server.ts is server-only code. However, if someone mistakenly tries to use the auth module in the client-side component:
// src/routes/login/+page.svelte
import { generateToken } from '$lib/server/auth'; // Build fails here
export let form;
function login() {
const token = generateToken(currentUserId); // This would never execute
}
The build will fail with an error message like: "Cannot import $lib/server/auth in a client component". This happens before your code ever ships to production, making it impossible to accidentally leak server code.
Put database and secrets here
The $lib/server directory is the natural home for any code that touches your database, reads environment variables containing secrets, or handles privileged operations. This includes database clients, ORM instances, external API clients with credentials, and utility functions that orchestrate sensitive operations.
Here's how a typical database setup looks:
// src/lib/server/database.ts
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
export async function createUser(email: string, passwordHash: string) {
return prisma.user.create({
data: { email, passwordHash }
});
}
export async function getUserWithPosts(userId: string) {
return prisma.user.findUnique({
where: { id: userId },
include: { posts: true }
});
}
API clients with authentication belong here too:
// src/lib/server/stripe.ts
import Stripe from 'stripe';
const apiKey = process.env.STRIPE_SECRET_KEY;
if (!apiKey) {
throw new Error('STRIPE_SECRET_KEY not set');
}
export const stripe = new Stripe(apiKey, {
apiVersion: '2023-10-16'
});
export async function createSubscription(customerId: string, priceId: string) {
return stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }]
});
}
And any function that reads secrets from environment:
// src/lib/server/config.ts
export const config = {
jwtSecret: process.env.JWT_SECRET || '',
databaseUrl: process.env.DATABASE_URL || '',
sendgridApiKey: process.env.SENDGRID_API_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
};
// Validate on server startup
if (!config.jwtSecret || !config.databaseUrl) {
throw new Error('Missing required environment variables');
}
The key principle: anything that accesses secrets or performs privileged database operations must live in $lib/server. This creates a hard boundary that no amount of developer error can cross.
Prevents accidental leaks
Accidental exposure of secrets is one of the most common security incidents in web applications. A developer refactors code, moves a function to what they think is a shared utility, and suddenly a database connection string or API key is accessible to the browser. With $lib/server, this category of accident is impossible.
Consider this scenario from a real project: we're building a feature where users can view their billing history. The developer creates a utility to fetch invoice data:
// src/lib/server/billing.ts
import { stripe } from './stripe';
export async function getCustomerInvoices(customerId: string) {
return stripe.invoices.list({
customer: customerId,
limit: 12
});
}
In the server load function, they use it correctly:
// src/routes/account/billing/+page.server.ts
import { getCustomerInvoices } from '$lib/server/billing';
export async function load({ locals }) {
const invoices = await getCustomerInvoices(locals.user.stripeCustomerId);
return { invoices };
}
But later, another developer needs to display a summary of recent charges in a tooltip component. They see getCustomerInvoices in the codebase and try to import it for client-side use. SvelteKit's build will immediately reject this:
// src/lib/BillingTooltip.svelte
import { getCustomerInvoices } from '$lib/server/billing'; // Build error
Without this protection, the developer might have worked around it by moving the function to $lib/billing.ts, which would bundle Stripe's secret key into the client JavaScript. With $lib/server, the error forces the correct solution: pass the summary data from the server load function through props.
Common mistakes
- Importing $lib/server in shared utilities: Avoid putting functions in
$lib/serverand then trying to use them in$libutilities that are imported by client code. The entire import chain is analyzed; if client code uses a utility that uses a server utility, the build fails. This is correct behavior—restructure so client code only gets what it needs. - Confusing $lib/server with +page.server.ts: Both are server-only, but they serve different purposes. Use
+page.server.tsfor page-specific server logic (load, actions). Use$lib/serverfor reusable server utilities. Server routes (+server.ts) can also import from$lib/server. - Storing secrets only in environment variables without $lib/server: While environment variables are private to the server, it's easy to accidentally reference them in client code. Wrap them in a
$lib/servermodule to make the boundary explicit and enforce it at build time. - Not validating required secrets on startup: Place validation logic in a
$lib/servermodule that loads on server startup. Fail fast if critical secrets are missing, rather than discovering the problem during a user request.
Key takeaways
$lib/serveris a directory convention that SvelteKit treats specially: code inside cannot be imported by client code, and this is enforced at build time.- Use
$lib/serverfor database clients, ORM instances, API clients with secrets, and any code that reads sensitive environment variables. - The build-time enforcement prevents accidental leaks of secrets and database credentials—entire categories of security vulnerabilities become impossible.
- Server-specific routes (
+page.server.ts,+server.ts) can freely import from$lib/server; client components cannot. - When you need to share data with the client, return it from server load functions or form actions—never export secrets or privileged operations to the client.






Comments
Loading comments…