Building a SaaS with SvelteKit: best practices explained
When you're shipping a production SaaS platform, the full-stack decisions you make early shape months of engineering effort downstream. SvelteKit gives us a lean, integrated framework, but without thoughtful architecture around authentication, billing, data persistence, and deployment, you'll find yourself retrofitting security holes and scaling headaches later. We'll walk through the complete stack—from session management to containerized deployment—using patterns we've proven across multiple production SaaS products.
Auth and sessions: hooks and cookies
Authentication is often the first load-bearing wall in a SaaS. SvelteKit's hooks system lets us validate sessions globally and consistently, avoiding scattered auth checks that become maintenance nightmares.
The pattern we use: store a signed, HTTP-only session cookie on login, then validate it in the handle hook before every request. This runs server-side and ensures no unauthenticated user can access protected routes or APIs.
// src/hooks.server.ts
import { redirect } from '@sveltejs/kit';
import { validateSession } from '$lib/auth';
export async function handle({ event, resolve }) {
const token = event.cookies.get('session_token');
if (token) {
const session = await validateSession(token);
if (session) {
event.locals.user = session.user;
event.locals.accountId = session.accountId;
}
}
// Redirect unauthenticated users away from protected routes
if (!event.locals.user && event.url.pathname.startsWith('/app/')) {
throw redirect(302, '/login');
}
return resolve(event);
}
In your login endpoint, generate a session token (we recommend using a cryptographically secure library like crypto.getRandomValues), hash it, store it in your database, and set it as an HTTP-only cookie with a reasonable TTL (typically 7–30 days depending on your security stance).
// src/routes/auth/login/+server.ts
import { json, redirect } from '@sveltejs/kit';
import { db } from '$lib/db';
import { verifyPassword } from '$lib/auth';
export async function POST({ request, cookies }) {
const { email, password } = await request.json();
const user = await db.query.users.findFirst({
where: (u) => eq(u.email, email),
});
if (!user || !verifyPassword(password, user.passwordHash)) {
return json({ error: 'Invalid credentials' }, { status: 401 });
}
const sessionToken = generateToken();
const sessionHash = hashToken(sessionToken);
await db.insert(sessions).values({
userId: user.id,
accountId: user.accountId,
token: sessionHash,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
cookies.set('session_token', sessionToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60,
path: '/',
});
return json({ success: true });
}
Always hash the token before storing it in the database. If your database is compromised, attackers gain nothing without hashing the cookie value again, which is computationally expensive. Use httpOnly and secure flags to prevent XSS and man-in-the-middle attacks.
Billing: Stripe Checkout with webhook routes
Stripe Checkout is the cleanest path to a working payment flow. You create a session server-side, redirect the user to a hosted checkout page, then validate the purchase via webhooks.
Create a checkout session when a user clicks "upgrade." Redirect them to Stripe, and they'll land back on your site once payment completes. The critical part: never trust client-side signals about payment status. Validate everything through Stripe webhooks.
// src/routes/billing/checkout/+server.ts
import { redirect, json } from '@sveltejs/kit';
import Stripe from 'stripe';
const stripe = new Stripe(import.meta.env.VITE_STRIPE_SECRET_KEY);
export async function POST({ locals, url }) {
if (!locals.user) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price: 'price_xxxxx', // Your Stripe price ID
quantity: 1,
},
],
mode: 'subscription',
success_url: `${url.origin}/billing/success`,
cancel_url: `${url.origin}/billing`,
customer_email: locals.user.email,
metadata: {
userId: locals.user.id,
accountId: locals.accountId,
},
});
throw redirect(303, session.url);
}
Now handle the webhook. Stripe sends a signed request; verify the signature using your webhook secret before processing.
// src/routes/webhooks/stripe/+server.ts
import { json } from '@sveltejs/kit';
import Stripe from 'stripe';
import { db } from '$lib/db';
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
const webhookSecret = import.meta.env.STRIPE_WEBHOOK_SECRET;
export async function POST({ request }) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch {
return json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const { userId, accountId } = session.metadata;
await db
.update(accounts)
.set({
stripeCustomerId: session.customer,
subscriptionStatus: 'active',
subscriptionPlan: 'pro',
})
.where(eq(accounts.id, accountId));
}
return json({ received: true });
}
Store the Stripe customer ID and subscription status in your accounts table. Use this to gate premium features in your app. Run webhook handlers asynchronously where possible; Stripe expects a 200 response quickly, not a slow database operation.
Data: Drizzle with Postgres
Drizzle ORM strikes a practical balance: type-safe SQL generation without the bloat of ORMs like Prisma. Pair it with Postgres for a reliable, feature-rich database.
Define your schema close to your SvelteKit routes. We've found this reduces schema drift and makes migrations less surprising.
// src/lib/db/schema.ts
import { pgTable, text, timestamp, uuid, varchar, boolean } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).unique().notNull(),
passwordHash: text('password_hash').notNull(),
accountId: uuid('account_id').references(() => accounts.id),
createdAt: timestamp('created_at').defaultNow(),
});
export const accounts = pgTable('accounts', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
stripeCustomerId: varchar('stripe_customer_id', { length: 255 }),
subscriptionStatus: varchar('subscription_status', { length: 50 }).default('free'),
subscriptionPlan: varchar('subscription_plan', { length: 50 }).default('free'),
createdAt: timestamp('created_at').defaultNow(),
});
export const sessions = pgTable('sessions', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id),
accountId: uuid('account_id').references(() => accounts.id),
token: text('token').notNull(),
expiresAt: timestamp('expires_at').notNull(),
});
Initialize a Drizzle client in a server-only module:
// src/lib/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(import.meta.env.DATABASE_URL);
export const db = drizzle(client);
When querying, use Drizzle's type-safe builders. This catches schema mismatches at build time, not at 3 AM in production.
// Example query in a +server.ts route
const user = await db.query.users.findFirst({
where: (u) => eq(u.email, email),
with: {
account: true,
},
});
Run migrations in version control. We use Drizzle's CLI to generate migrations from schema changes, then apply them during deployment.
Deploy: adapter-node on containers or a managed platform
SvelteKit's adapter-node produces a self-contained Node.js server. This gives you flexibility: run it in Docker, on a VPS, or on Vercel/Railway with minimal configuration.
For team-controlled deployments, we prefer containerization. Create a Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "build/index.js"]
Set environment variables at runtime, not build time. Store secrets in your orchestrator (Docker Compose, Kubernetes, AWS ECS, etc.), never in code.
// svelte.config.js
import adapter from '@sveltejs/adapter-node';
export default {
kit: {
adapter: adapter({
out: 'build',
precompress: true,
}),
env: {
dir: process.cwd(),
privatePrefix: 'PRIVATE_',
},
},
};
For managed platforms like Railway or Fly.io, deploy directly from your Git repository. They handle the build, secrets, and scaling for you. This reduces operational overhead when you're early-stage.
Always use a reverse proxy (Nginx, Caddy, or your platform's built-in) in front of your Node server. This handles TLS termination, gzip compression, and static asset caching—tasks better handled outside your application code.
Common mistakes
- Storing session tokens unhashed: If you hash the cookie value when you set it, compare hashes on every request. This costs CPU but prevents token replay if your database leaks.
- Trusting client-side payment status: Always validate subscription state via webhooks or a server-side API call to Stripe. Never gate premium features based on JavaScript that runs in the browser.
- Running database migrations at container startup: Migrations should be a separate, controlled step. Run them in a Job or manually before deploying your app. Container startup must be idempotent and fast.
- Logging secrets: Rotate secrets regularly and scan your logs for accidental leaks. Use environment-based secret injection, never hardcoded values.
- Skipping CORS configuration: If your API serves multiple origins, be explicit. Avoid
Access-Control-Allow-Origin: *on endpoints that modify data.
Key takeaways
- Use SvelteKit's
handlehook to enforce authentication globally; validate sessions server-side on every request. - Implement Stripe webhooks as your source of truth for billing state; never rely on client-side signals about payment success.
- Choose Drizzle for type safety and Postgres for reliability; keep your schema versioned and migrations automated.
- Deploy with
adapter-nodeand containerization for control, or use a managed platform if you want simplicity; always keep secrets out of code. - Test the full flow end-to-end: sign up, upgrade, receive a webhook, verify the subscription is active. Missing one link breaks the SaaS.






Comments
Loading comments…