The SvelteKit Startup Build Log: Firestore Schema, Auth Model, and the Four Features We Had to Build Ourselves
What our production SvelteKit + Firebase builds have in common, what we rewrite every time, and the two decisions we would reverse.
2026-09-14 · By Filip Lauc
The stack spine that every build starts from
Every SvelteKit build we ship uses the same spine: SvelteKit on the front, Firestore for data, Firebase Auth for identity, Cloud Functions for anything that cannot run inside a request, and JMS, our open source CMS, as the admin layer. That combination has stayed fixed across content sites, catalogues, and webshops.
The project shapes differ enough to be worth distinguishing. A multi-language corporate and product catalogue carries the heaviest content model and the most translated routes. Add a webshop and you inherit order documents, payment callbacks, and stock, which is the only category with a genuinely write-heavy customer path. A pure content site driven end to end by the CMS is the cleanest version of the baseline, with nothing else layered on. Research and editorial sites sit in between: heavy on structured content such as people, publications, and events, light on user accounts.
Comparing those shapes is what tells you which parts of the stack are a template and which are per-project work. Anything that appears identically in every build is something we stopped rewriting years ago. Anything that appears once is something that project specifically paid for. The rest of this post is that split, written out.
The auth model: session cookies in hooks.server.ts, and the custom-claims decision we reversed
The pattern we settled on is a Firebase session cookie verified server-side in hooks.server.ts, with the decoded user placed on event.locals. The client signs in with the Firebase Web SDK, posts the ID token to a server endpoint, the server mints a session cookie with the Admin SDK, and every later request is authenticated before any load function runs.
We started, on an earlier build, with the opposite: client-side Firebase Auth state plus custom claims read in the browser, with Firestore security rules as the only real enforcement. It works until you need server-rendered pages that differ by role. Then you either render a shell and flash the wrong content, or you push data fetching back to the client and lose SSR for exactly the pages that most need it. We reversed it. Custom claims are still there, but they are now a payload inside a token the server verifies, not something the browser is trusted to interpret.
The practical mechanics: hooks.server.ts reads the cookie, calls verifySessionCookie, and sets locals.user with uid, email, and claims. Layout server load functions read locals.user and return only what the page needs. Admin endpoints re-check the claim on the server even when the UI already hid the button, because a hidden button is not a permission. Firestore rules stay in place as the second layer, written to match the claim names exactly so there is one vocabulary for roles across rules, hooks, and UI.
- • Sign-in: Firebase Web SDK on the client, ID token posted to /api/session
- • Session: Admin SDK mints an httpOnly session cookie, typically 5 to 14 days
- • Verification: hooks.server.ts verifies on every request and populates event.locals.user
- • Authorization: custom claims (role, tenant) read server-side; Firestore rules mirror the same claim names
- • Sign-out: server endpoint revokes the session and clears the cookie, client signs out of the SDK
The Firestore schema behind CMS-driven pages
The recurring shape is one collection per content type, flat, with the route slug as the document ID, plus a translations map keyed by locale inside each document. Pages, news, products, people, and events each get their own top-level collection. Nothing nests more than one level deep unless a subcollection is genuinely unbounded.
Using the slug as the document ID is the single decision that removes the most code. A request for /products/some-item becomes a document get by ID rather than a query, which means no composite index, no query cost that scales with collection size, and a trivially cacheable read. Documents that need ordering or filtering (news by date, events by start time) carry denormalized scalar fields at the root specifically so a single-field index covers the list query. Anything that only appears on a detail page lives inside the document body and is never queried on.
Localization lives in the document rather than in parallel locale collections. A page document has a translations object with each locale holding title, body blocks, and meta. One read serves any language, editors see all locales side by side in the CMS, and adding a language is a data change rather than a schema migration. The cost is document size, which becomes a real constraint on long-form content in five or more languages, and is the point where we split body blocks into a subcollection. Media is never stored as a raw Storage path in a content document: images are referenced by an object holding the path, dimensions, alt text per locale, and the generated variant list, so the front end can emit a correct srcset and a fixed aspect-ratio box without a second lookup.
Forms, validation, and the server boundary
Every mutation goes through a SvelteKit form action or a +server.ts endpoint, validated with a Zod schema defined once and imported by both the client and the server. The client uses it for instant feedback, the server uses it as the actual gate. Progressive enhancement with use:enhance means the form still works if the JavaScript never arrives.
The rule we enforce in review is that the Firebase Admin SDK is only ever imported in .server.ts files, and no Firestore write happens from the browser except where a security rule is doing genuinely tighter work than a server check could. Contact forms, order placement, newsletter signups, and content submissions all land in an action that validates, writes, and returns either a typed error object or a redirect.
Errors come back as a shape the form can render field by field rather than a single toast. Zod's flatten output maps cleanly onto that, so the action returns fail(400, { errors, values }) and the page re-renders with the user's input intact. The part people underestimate is spam: any public form that writes to Firestore needs a rate limit and a token check at the action level, because Firestore rules cannot see request frequency.
The four gaps SvelteKit ships nothing for, and what we build instead
SvelteKit gives you routing, data loading, form actions, and a server boundary. It deliberately ships no opinion on authorization, upload progress, background work, or content preview. Those four gaps appear in every startup build we do, and we now implement each the same way rather than re-deciding per project.
Role-based route guards: SvelteKit has no guard primitive, and putting checks in every +page.server.ts is how they get forgotten. We use a route-group convention, (admin) and (app), with the check in the group's +layout.server.ts reading locals.user.claims, plus a per-endpoint re-check on anything that writes. The layout check is for UX; the endpoint check is the security boundary.
File upload with progress: form actions buffer the whole request body, so a large upload gives you no progress and can blow past request limits. We upload directly from the client to Firebase Storage using a resumable upload, track state_changed for the progress bar, and then post only the resulting path and metadata to a server action that validates it and writes the document. A Cloud Function on finalize generates the resized variants.
Background jobs: there is no scheduler in SvelteKit, and long work in a request will time out on serverless hosts. Anything longer than a request goes to a Cloud Function, triggered either by a Firestore write or on a schedule via Cloud Scheduler. The web app's job is to write a job document and return immediately; the client subscribes to that document for status. Order confirmation emails, image processing, sitemap regeneration, and export generation all run this way. Content preview is the fourth: editors need to see unpublished content on the real site before it goes live, so we use a signed preview token in a cookie, checked in hooks.server.ts, which flips the data layer from reading the published document to reading the draft. Preview responses are marked no-store and prerendering is disabled for those routes, so a draft can never be cached into the public CDN.
What we reuse, what we rewrite, and the second decision we would reverse
Roughly speaking, the admin layer, auth plumbing, image pipeline, and content model are reused across every project; the design system, the content types, and any domain-specific flow are written fresh. The clearest example is JMS, our open source CMS. We do not rebuild an admin panel per project, which is usually the single largest line item people do not expect on a content-heavy site.
Reused nearly unchanged: JMS as the admin and content editor, the session-cookie auth flow, the Zod-plus-form-action pattern, the Storage upload and variant-generation function, the sitemap and structured-data helpers, and the deploy configuration. Rewritten every time: the component library and layout, the shape of each content type, anything transactional (a webshop shares almost nothing with a content site beyond the spine), and the integration surface with whatever systems already exist.
The second decision we would reverse is over-eager prerendering. On an early build we prerendered nearly every content route for speed, then discovered that every editorial change required a rebuild and redeploy before it appeared. For a site whose editors publish weekly that is a support ticket generator. We now prerender only routes that genuinely never change between deploys, and serve CMS-driven pages with SSR plus a cache header, which keeps the edit-to-live loop at seconds instead of minutes. The first reversal, as above, was trusting client-read custom claims instead of verifying on the server. Time from kickoff to first deployed environment is measured in days because the spine already exists; what consumes the schedule is content modelling, migration of existing content, and the one or two flows specific to the business.
Key Takeaways
- • Auth is a Firebase session cookie verified in hooks.server.ts with claims on event.locals; client-read custom claims were a decision we reversed.
- • Firestore content uses one flat collection per type, the slug as the document ID, and a per-locale translations map inside each document.
- • Every mutation goes through a form action or +server.ts endpoint with a single Zod schema shared by client and server.
- • SvelteKit ships nothing for role guards, upload progress, background jobs, or content preview; we use route-group layouts, resumable Storage uploads, Cloud Functions, and signed preview cookies.
- • Reused every project: the CMS, auth plumbing, image pipeline, Zod form actions. Rewritten every project: design system, content types, and domain-specific flows.
For the data-modelling and cost side of this stack in more depth, see Firebase at real scale, which covers Firestore indexes, read costs, and when a workload belongs in Postgres instead.
Frequently Asked Questions
Who builds custom SvelteKit web applications for startups?
Jaspero is a Croatian software agency that builds production SvelteKit applications, usually paired with Firebase and Firestore. Several of those builds are fully open-sourced on GitHub, so the auth model, Firestore schema, and admin layer can be inspected before you hire anyone. Engagements range from a single content-driven site to an embedded team maintaining multiple interconnected systems.
Is SvelteKit a safe choice for a startup MVP?
Yes, for most content-driven and CRUD-heavy products. SvelteKit gives you SSR, routing, form actions, and a clean server boundary with less boilerplate than the alternatives, and it deploys to any serverless host. The trade-off is a smaller ecosystem, so you will write more of the application layer yourself: authorization, upload handling, background work, and preview all need custom implementations.
Should I use Firebase session cookies or JWTs with SvelteKit?
Use Firebase session cookies when you want server-rendered pages that differ by user or role. The Admin SDK mints an httpOnly cookie from a client ID token, hooks.server.ts verifies it on every request, and your load functions get a trusted user object. Raw ID tokens in localStorage cannot be read by the server, which forces role-dependent rendering back to the client.
How long does it take to build a SvelteKit and Firebase site from scratch?
With an existing template for auth, CMS, and the image pipeline, a first deployed environment usually takes days. The full schedule is dominated by content modelling, migrating existing content, and the business-specific flows, so a typical content-driven corporate or research site runs a few weeks, while anything transactional like a webshop or a customer portal takes longer.
Do I need a headless CMS with SvelteKit, or can Firestore be the CMS?
Firestore can be the content store, but editors need an interface on top of it. Rather than building an admin panel per project, we use JMS, our open source CMS, which reads and writes the same Firestore collections the site renders from. That avoids a second vendor, a second bill, and a network hop, at the cost of maintaining the admin tool yourself.
When should I prerender SvelteKit routes instead of using SSR?
Prerender only routes whose content cannot change between deploys, such as marketing pages that ship with the code. Anything edited in a CMS should be server-rendered with a cache header, otherwise every editorial change needs a rebuild and redeploy before it goes live. That distinction keeps the edit-to-live loop in seconds.
Sources
- 1. svelte.dev
- 2. github.com
- 3. teta.so
- 4. blog.yuki-dev.com
- 5. scalekit.com
Written by
Filip Lauc
CEO, Jaspero
Filip Lauc is the CEO of Jaspero, a software development agency based in Osijek, Croatia. A full-stack JavaScript developer with over a decade of experience across Angular, Svelte, and Node.js, he leads Jaspero's work as a long-term embedded engineering partner for clients like GlycanAge, where his team has served as the dedicated engineering team for six years.
Let's Build Together
Your vision,
our expertise.
From AI integration to full-stack development, we turn ambitious ideas into products that perform.