Opinionated SvelteKit 5 starter for internal web apps on Vercel + Neon. Distils the conventions of a project that took ~30 PRs to converge on this shape, so new projects get the same foundation on day one.
@sveltejs/adapter-vercel targeting
Node 22 in region fra1.@sentry/sveltekit wired on server + client — a no-op
until you set PUBLIC_SENTRY_DSN, so it never gets in the way locally.src/lib/server/env.ts. Reuse it for form input and API payloads.src/routes/+error.svelte so thrown errors render
a real page instead of the framework fallback..github/workflows/ci.yml runs lint → typecheck → test → build on
every PR and push to main, with concurrency cancellation.vX.Y.Z tag + CHANGELOG entry on every main merge,
driven by Conventional Commits.db:verify-restore script) and provider-by-provider secret rotation.Button is pre-installed so you can
immediately bunx shadcn-svelte add ... more components.After clicking Use this template on GitHub, do this once per derived project:
package.json → namerelease-please-config.json → packages["."].package-namevercel.json (fra1 is the default).app.html — change lang="en" if your UI isn't English; set
theme-color to match your brand.main (admin → Settings → Branches):verify status check before mergingdocs/runbooks/secret-rotation.md, delete every section marked
"Skip if not used" that doesn't apply..github/dependabot.yml, delete the grouping rules for libraries you
never installed (otherwise they're harmless but noisy).src/hooks.server.ts
currently pass-throughs every request (after Sentry).PUBLIC_SENTRY_DSN (Production + Preview) if you want error
tracking — otherwise Sentry stays a no-op.scripts/verify-restore.ts — the TABLES array is empty by
design; add one entry per table you want the backup-restore drill to verify.| Layer | Choice |
|---|---|
| Runtime | Node 22 (Vercel) |
| Package manager | Bun (local dev) |
| Framework | SvelteKit 5 + TypeScript |
| Styling | Tailwind CSS 4.1 + shadcn-svelte |
| Database | Neon Postgres (via Vercel Marketplace integration) |
| ORM | Drizzle + @neondatabase/serverless |
| Validation | Valibot |
| Observability | Sentry (@sentry/sveltekit, optional) |
| Hosting | Vercel (region fra1 by default) |
| Lint / format | ESLint 9 + Prettier 3 |
| Git hooks | Husky + lint-staged |
| Tests | Vitest |
| Auth (add when needed) | BetterAuth + Drizzle adapter |
| Email (add when needed) | Resend |
# 1. Install dependencies
bun install
# 2. Copy the env template, then fill in real values (or run `vercel env pull`)
cp .env.example .env.local
# 3. Apply database migrations (once you have a real DATABASE_URL)
bun run db:migrate
# 4. Start the dev server (http://localhost:5173)
bun run dev
Sentry is pre-wired; auth and email are documented here as the blessed paths but not scaffolded — add them when a project actually needs them. Check the current API with the Context7 MCP before pasting; these libraries move fast.
Server init lives in src/instrumentation.server.ts, client init in
src/hooks.client.ts, and sentryHandle() wraps src/hooks.server.ts. It's a
no-op until a DSN is present. To turn it on:
# Set the DSN (Production + Preview, and .env.local for dev)
PUBLIC_SENTRY_DSN="https://[email protected]/XXXX"
To upload source maps on deploy, also set SENTRY_AUTH_TOKEN, SENTRY_ORG,
and SENTRY_PROJECT — the Vite plugin only runs when the token is present
(vite.config.ts), so local/CI builds stay clean.
bun add better-auth
// src/lib/server/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: { enabled: true },
// `sveltekitCookies` must be the LAST plugin.
plugins: [sveltekitCookies(getRequestEvent)]
});
// src/hooks.server.ts — replace the no-op handleApp with the BetterAuth handler
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment';
import { auth } from '$lib/server/auth';
const handleApp: Handle = ({ event, resolve }) =>
svelteKitHandler({ event, resolve, auth, building });
Then generate + apply the auth tables, and set the secret:
bunx @better-auth/cli generate # writes auth tables into your Drizzle schema
bun run db:generate && bun run db:migrate
# .env: BETTER_AUTH_SECRET=$(openssl rand -hex 32), BETTER_AUTH_URL=<app url>
bun add resend
// src/lib/server/email.ts
import { Resend } from 'resend';
import { env } from '$env/dynamic/private';
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
export async function sendEmail(opts: { to: string; subject: string; html: string }) {
if (!resend) {
// No key configured — log and no-op so dev/CI don't fail.
console.warn('RESEND_API_KEY unset; skipping email:', opts.subject);
return;
}
await resend.emails.send({ from: '[email protected]', ...opts });
}
| Command | Purpose |
|---|---|
bun run dev |
Start the Vite dev server on port 5173 |
bun run build |
Production build via @sveltejs/adapter-vercel |
bun run preview |
Preview the production build locally |
bun run check |
svelte-kit sync + svelte-check typecheck |
bun run lint |
prettier --check + eslint |
bun run format |
Rewrite files with Prettier |
bun run test |
Run Vitest once |
bun run test:watch |
Vitest in watch mode |
bun run db:generate |
Generate a new SQL migration from schema.ts changes |
bun run db:migrate |
Apply pending migrations to DATABASE_URL |
bun run db:push |
Push schema changes without a migration (dev convenience) |
bun run db:studio |
Launch Drizzle Studio against DATABASE_URL |
bun run db:verify-restore |
Compare two Neon DBs row-by-row (see runbook) |
See docs/runbooks/neon-backup-restore.md for the restore procedure that
db:verify-restore underpins.
.github/workflows/ci.yml runs the verify job on every PR and push to
main. In order:
bun install --frozen-lockfilebun run lint — Prettier + ESLintbun run check — svelte-kit sync + svelte-checkbun run test — Vitestbun run build — vite build with a dummy DATABASE_URLConcurrency cancels superseded runs on the same branch/PR. The Husky
pre-commit hook (.husky/pre-commit) runs lint-staged + svelte-check so
typecheck regressions are caught locally before they hit CI.
Fallow runs on every PR (fallow audit --fail-on-issues) and fails the
check if the PR introduces new dead code, complexity violations, or unlisted
dependencies. Inherited issues don't block — only the delta matters. Run
locally:
bunx fallow # full report
bunx fallow fix # auto-fix the safe stuff
bunx fallow audit # same gate as CI
Config: .fallowrc.json. Tune entry,
ignoreDependencies, and health.* thresholds for your project's actual
shape — the defaults assume a typical SvelteKit + Vercel + Drizzle app.
Vercel preview deployments are handled by Vercel's own GitHub integration — not duplicated here.
A merge to main triggers
release-please via
.github/workflows/release-please.yml. It opens (or updates) a release PR
that bumps the version in .release-please-manifest.json, regenerates
CHANGELOG.md, and tags vX.Y.Z when the release PR is merged.
The changelog is grouped by commit type, so stick to Conventional Commits:
feat: ... → new featurefix: ... → bug fixrefactor: ... → behaviour-preserving changeperf: ... → performance improvementchore: ... → tooling / housekeeping (hidden from CHANGELOG)feat!: ... or footer BREAKING CHANGE: → major bumpThese steps are not scripted because they require interactive auth and Marketplace consent.
Link the project
bunx vercel link
Provision Neon via the Vercel Marketplace
Vercel dashboard → project → Storage → Browse Marketplace → Neon
→ create a Postgres database in the region matching vercel.json.
The integration injects DATABASE_URL (and DATABASE_URL_UNPOOLED, plus
PG* variants) into Production and Preview automatically.
Set the remaining env vars for Production and Preview — one
vercel env add per entry in .env.example, or use the Vercel dashboard.
Pull them locally
vercel env pull .env.local
Restrict preview access (Vercel dashboard → project → Settings → Deployment Protection): enable Vercel Authentication (team SSO) or Password Protection so preview URLs aren't world-readable.
Confirm the region in vercel.json — must match where the Neon DB
lives.
src/
├── app.css # Tailwind + shadcn theme variables
├── app.d.ts
├── app.html
├── instrumentation.server.ts # Sentry server init (no-op without a DSN)
├── hooks.server.ts # sentryHandle + pass-through; add auth here
├── hooks.client.ts # Sentry client init
├── lib/
│ ├── components/ui/ # shadcn-svelte components (Button installed)
│ ├── server/
│ │ ├── env.ts # Valibot-validated env (fail-fast at load)
│ │ └── db/
│ │ ├── index.ts # Drizzle client (Neon HTTP driver)
│ │ └── schema.ts # Empty — add your tables here
│ └── utils.ts # cn() helper + shadcn type helpers
└── routes/
├── +layout.svelte
├── +error.svelte # Default error page
└── +page.svelte # Placeholder home page
drizzle/ # Generated migrations land here
docs/runbooks/ # Secret rotation + Neon backup-restore
scripts/verify-restore.ts # Companion to the Neon runbook
MIT — see LICENSE (add one if you fork this template publicly).