A modular CMS built with SvelteKit for Cloudflare. One repo, two subdomains:
www.example.com— public website (SSR)cms.example.com— admin panel (authenticated)
- Package manager: pnpm (CI uses pnpm 9; lockfile is
pnpm-lock.yaml) - Framework: SvelteKit 2 + Svelte 5
- Bundler: Vite 5.4.x (
~5.4.21) — pinned because Vite 6 currently trips SvelteKit’svite-plugin-sveltekit-guardduring production builds (client bundle /hooks.server.tsboundary). Prefer unpinning after upgrading SvelteKit when the ecosystem clearly supports Vite 6 for Cloudflare builds. - Styling: Tailwind CSS 4 + shadcn/ui (bits-ui)
- Database: Cloudflare D1 via Drizzle ORM
- Media: Cloudflare R2
- Cache: Cloudflare KV
- Auth: Better Auth (email/password, D1-backed sessions)
- i18n: Paraglide JS 2.0 (inlang) — compile-time, type-safe translations
- Deployment: Cloudflare Workers via
wrangler
- Single SvelteKit app with route groups:
(www)for public,(cms)for admin - Path-based surface routing via
hooks.server.ts—/cms/*is admin, everything else is public - Content storage is D1-backed (
D1ContentProvider); theContentProviderinterface is kept as a seam for tests - Media always stored in R2, metadata in D1
src/lib/server/content/— Content provider interface, D1 provider, Drizzle schemasrc/lib/server/auth/— Better Auth setup, permissionssrc/lib/server/media/— R2 media servicesrc/lib/i18n/— Locale helperssrc/lib/paraglide/— Auto-generated by Paraglide (gitignored)messages/— Translation JSON files (th.json, en.json)project.inlang/— Inlang project settingssrc/routes/(www)/— Public site routessrc/routes/(cms)/— CMS admin routessrc/routes/api/auth/— Auth API endpointsdrizzle/— D1 migration files
pnpm dev # Local dev server
pnpm build # Build for production
pnpm run db:generate # Generate D1 migration from schema changes
pnpm run db:migrate # Apply migrations locally
pnpm run db:migrate:remote # Apply migrations to production D1
pnpm run deploy # Build + deploy to Cloudflare Workers- Default locale is English (
en). Thai (th) is the secondary locale. - Articles have shared slug/media across languages, separate markdown per locale (EN/TH)
- Localizations stored in separate tables (
article_localizations,category_localizations, etc.) - Roles: super_admin > admin > editor > author
- Slugs are always English-only ASCII (
^[a-z0-9]+(?:-[a-z0-9]+)*$) - Slugs are shared across all locales — there is no per-language slug
- Slugs are auto-generated from the English title via
slugify()in$lib/utils - The English (
en) localization is required when creating an article — the slug is derived fromlocalizations.en.title - Non-ASCII characters are stripped, not transliterated. A Thai-only title cannot produce a slug; the user must supply an English title (or an explicit
slug) - Admins may rename the slug via
updateArticle({ slug }), but it is then re-normalized throughslugify()before being stored
- All IDs use
nanoid() - Dates stored as ISO strings in D1 (SQLite text)
- Content provider is injected into
localsvia hooks - Auth guard is in
(cms)/+layout.server.ts - Route access control is enforced in
hooks.server.ts(subdomain check)
- Paraglide JS (
$lib/paraglide/messages) — UI strings (buttons, labels, navigation). Compile-time, type-safe, tree-shakable. Add messages tomessages/th.jsonandmessages/en.json, import asimport * as m from '$lib/paraglide/messages', use asm.key_name(). - Content localizations — Article/category/tag body text stored per locale in D1 (
article_localizationstable). These are user-generated content, not UI translations.
Do not mix these two layers. Paraglide is for the app shell; content localizations are for CMS data.