SvelteKit App Architecture
See the SvelteKit docs for routing primitives and load function basics — here's the architectural pattern we apply on top of them.
Why the default falls apart
When you first build a SvelteKit app, it's tempting to put everything in route files: fetch the data, transform it, pass it to the template, handle forms — all in one place. This works for a demo but collapses quickly in a real product. Route files become thousand-line monsters, business logic leaks everywhere, and changing a data shape breaks six files at once.
The architecture here solves this with a strict layering rule: routes are thin coordinators, features own everything about a domain. A route file calls one function, gets back typed data, and hands it to a component. That's it. All the real work — DB access, transformation, validation — happens in feature libs.
What goes wrong without it:
- Raw Firestore/DB objects flow directly to the UI, so any DB schema change breaks every component that touches it
- Business logic is scattered across route files making it impossible to test or reuse
- Type safety degrades as
anycasts accumulate where shapes don't line up
The core pattern: Route → Feature → View Model → Component
Every page follows the same flow:
+page.server.ts → feature server function → service → view model → componentStep 1: The route (+page.server.ts)
The route is thin. It gets the session, calls the feature's server function, and returns what it gets back.
// apps/web/src/routes/foo/+page.server.ts
import { getFooPage } from "@readership/feature-foo";
export const load = async ({ locals }) => {
const session = await getSession(locals);
return getFooPage(session.user.handle);
};Notice what is NOT here: no DB queries, no data transformation, no business logic. The route doesn't know how getFooPage works.
Step 2: The feature server function (feature-foo/src/server.ts)
The server function is the feature's public server API. It orchestrates calls to services.
// libs/feature-foo/src/server.ts
import { fooService } from "./services/foo";
export async function getFooPage(handle: string) {
return fooService.getFoo(handle); // returns FooViewModel
}Step 3: The view model (feature-foo/src/view-models/foo.ts)
The view model is a Zod schema that defines exactly what the UI needs. It's the contract between the backend and the frontend. If a field isn't in the view model, it isn't in the UI — no accidental data leaks.
// libs/feature-foo/src/view-models/foo.ts
import { z } from "zod";
export const FooViewModelSchema = z.object({
id: z.string(),
title: z.string(),
// only fields the UI renders — not the full DB document
});
export type FooViewModel = z.infer<typeof FooViewModelSchema>;When you need to add a new field to the UI, add it here first. Zod validates at runtime, so if the data from the DB doesn't match, you get an error at the service boundary — not a silent undefined in your component.
Step 4: The component (+page.svelte)
The page component receives typed data from $props() and renders it. It does not fetch, transform, or mutate.
<script lang="ts">
import { FooCard } from '@readership/feature-foo';
let { data } = $props();
</script>
<FooCard foo={data.foo} />Feature modules vs shared infrastructure
Not all code belongs in a feature lib. Here's how to decide:
Feature modules (libs/feature-X/) own a slice of product functionality end-to-end: the view model, the service, the components, and the server function. They contain business logic and know about domain entities like Publications, Series, Users, and Posts.
Shared infrastructure has no business logic and is reusable across any feature:
| Module | Purpose |
|---|---|
libs/ui/ | Generic, unstyled or lightly-styled UI primitives (Button, Card, Tabs, …) |
libs/shared/ | Pure utilities with no framework dependencies (formatters, validators, …) |
libs/models/ | Shared TypeScript types used across features |
libs/db/ | Firestore document definitions — raw data layer |
libs/editor/ | Tiptap editor integration — framework component, not a feature |
libs/feature-shell/ | App shell: layout, navigation, session helpers — owns the wrapper but not the content |
The decision rule: if the code knows about a business entity (post, series, publication), it belongs in a feature lib. If it's reusable across multiple features with no business knowledge, it belongs in shared infrastructure.
Namespacing libs for larger monorepos: The libs/feature-* flat naming works well initially. As the monorepo grows, feature libs can be organized under namespaced subdirectories — e.g., libs/billing/feature-invoices/, libs/billing/feature-payments/ — keeping related features grouped without changing any of the import or isolation rules. Start flat; namespace when the flat list becomes hard to navigate.
Examples:
Avatar.sveltethat takessrcandname→libs/ui/AuthorAvatar.sveltethat takes anAuthorview model →libs/feature-X/formatDate()utility →libs/shared/getSeriesPage()server function →libs/feature-series/
Cross-cutting concerns (logger, analytics, error tracking) are their own lib (e.g. libs/logger) or a third-party dep — not a "shared" util that accumulates over time.
Within-app structure
Code that belongs to the app itself (not a reusable feature lib) lives under apps/web/src/:
apps/web/src/
routes/ # thin coordinators only — call feature server fns, own form actions
hooks/ # app-level hooks
validators/ # app-level validators (route params, session checks)
mappers/ # app-level mappers
db/ # Firestore/DB entities and services owned solely by this app
generated/ # generated code (never hand-edit)Co-location of logic inside a route directory is discouraged. Keep route files as thin coordinators. The only exception is private types, mappers, or objects that are used exclusively by that single route and have no reason to exist elsewhere.
Generated code (OpenAPI types, Firestore SDK types, etc.) goes in generated/ and must never be hand-edited. If you need to change generated output, update the generator or schema and re-run it.
Rules
- Routes own form actions; features own data shape
- View models are the contract: if it's not in the VM, it's not in the UI
- No
any— all data flows through typed view models - Server functions call services; services call DB documents
Feature dependency rules (DAG):
- Most features: no cross-feature imports
- Pervasive features (auth, user-profile, feature-shell): CAN be imported from other feature libs, but ONLY components and public hooks/stores — never internal implementation
- The feature dependency graph must be a DAG — no cycles allowed. If importing feature A from feature B would create a cycle, redesign.
- For cross-feature coordination, in order of preference:
- Shared Svelte stores that features publish to and subscribe from
- SvelteKit's load data/invalidation (page data already in scope at the route)
- Complex coordination: handle in the route/page file, or create a "widget" Svelte component in
libs/shared/orapps/web/src/that composes multiple features