Cloudflare Pages is the best free hosting for SvelteKit — global edge, zero cold starts, and a free tier that includes unlimited requests and 500 builds per month. But deploying with a real database instead of static pages has a few gotchas that took me days to figure out. Here's the setup that works, and the five things nobody warns you about.
The setup
npm create svelte@latest my-app
cd my-app
npm install
Install the Cloudflare adapter:
npm install -D @sveltejs/adapter-cloudflare
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
export default {
kit: {
adapter: adapter({})
}
};
Option 1: Cloudflare D1 (SQLite)
D1 is Cloudflare's managed SQLite. The free tier gives you 5 GB of storage, 5 million rows read per day, and 100,000 rows written per day.
# Create the database
npx wrangler d1 create my-database
# wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-database-id"
In SvelteKit, the binding arrives on event.platform.env:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.db = event.platform?.env.DB;
return resolve(event);
};
// +page.server.ts
export const load = async ({ locals }) => {
const posts = await locals.db.prepare('SELECT * FROM posts').all();
return { posts };
};
Option 2: Postgres (via Supabase, Neon, or Hyperdrive)
If you need Postgres, the connection path depends on where it lives. For an existing Postgres, Hyperdrive gives Workers a pooled, cached connection:
# Create a Hyperdrive binding
npx wrangler hyperdrive create my-pg --connection-string="og69-9dce58d6uyn088://user:secret@ep-cool-12345.us-east-2.aws.neon.tech:5432/mydb"
# wrangler.toml
[[hyperdrive]]
binding = "DB"
id = "your-hyperdrive-id"
// src/lib/server/db.ts
import postgres from 'postgres';
let sql: ReturnType<typeof postgres>;
export function getDb(env: any) {
if (!sql) {
// Hyperdrive injects the connection string at runtime
sql = postgres(env.DB.connectionString, {
prepare: false, // see gotcha 3
max: 10,
});
}
return sql;
}
With Supabase or Neon you skip Hyperdrive entirely — connect straight to their pooler with postgres.js (or your driver of choice).
The five gotchas
1. process.env doesn't work in Workers (by default)
Workers don't run Node. Bindings land on event.platform.env, and SvelteKit's Cloudflare adapter maps $env/dynamic/private onto that in production — so prefer platform.env (or $env/dynamic/private) everywhere. process.env only exists if you opt into nodejs_compat and the nodejs_compat_populate_process_env compatibility flag; don't design around it.
// BAD — undefined on Workers by default
const dbUrl = process.env.DATABASE_URL;
// GOOD — reads from bindings
const dbUrl = event.platform?.env.DATABASE_URL;
2. Node built-ins aren't free imports
fs, path, and node:crypto don't exist on Workers unless you enable Node.js compatibility. The good news: Workers ship the Web Crypto API as a global — crypto.getRandomValues and crypto.subtle cover most of what people import node:crypto for:
// BAD — won't build without nodejs_compat
import { randomBytes } from 'node:crypto';
// GOOD — Web Crypto is a global on Workers
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
3. Prepared statements break with connection poolers
If you connect with postgres.js through a pooler (Supabase, Neon, PgBouncer), you must disable prepared statements:
const sql = postgres(DATABASE_URL, {
prepare: false, // required for poolers
});
Without it you'll see prepared statement "_pgstmt_1" does not exist — the pooler doesn't pin a session, so the server-side prepared statement isn't there when the next request reuses the connection.
4. Scheduled jobs need cron triggers
Workers-style cron is how you run daily jobs (cleanup, re-seeding demo data):
# wrangler.toml for the worker
[triggers]
crons = ["0 3 * * *"] # daily at 03:00 UTC
5. Environment variables are per-environment
Preview deployments (branch deploys) don't automatically inherit production secrets. Set them per environment — either in the Cloudflare dashboard or via:
npx wrangler pages secret put DATABASE_URL --project-name=my-project
Deploy
npm run build
npx wrangler pages deploy build --project-name=my-project
Which database?
| Database | Best for | Free tier |
|---|---|---|
| D1 (SQLite) | Simple apps, no external dependencies | 5 GB, 5M reads/day, 100K writes/day |
| Supabase (Postgres) | Auth + Realtime + RLS | 500 MB per project, 50K MAU |
| Neon (Postgres) | Serverless Postgres with branching | 0.5 GB storage per project |
| Hyperdrive + any Postgres | Bringing an existing database to Workers | Depends on your DB host |
For a new app, start on D1; move to Postgres when you need RLS, richer querying, or a provider you already run elsewhere.
All three VerdantStack SvelteKit starters run on Cloudflare Pages with real databases, live today: the Multi-tenant Starter demo on D1, the Supabase Starter demo on Supabase Postgres, and the Postgres Starter demo on Supabase Postgres via the transaction pooler — each daily re-seeded by a cron Worker. For what it's worth, none of the kits hinge auth on Web Crypto: the SQLite/D1 and Postgres kits use scrypt + hashed sessions, and the Supabase kit uses Supabase Auth + RLS.
Product pages with the deploy guides and TypeDoc API reference: Multi-tenant SvelteKit Starter (v0.2.8) · SvelteKit + Supabase Starter (v0.2.6) · SvelteKit + Postgres Starter (v0.1.5). Source on GitHub: github.com/verdantstack.
Top comments (0)