Every six months or so I open a new repo with SvelteKit on the front and Fastify on the back, and every time I am tempted to be clever about the layout. I have stopped being clever. The boring shape below is the one I keep coming back to, and it carries a surprising amount of weight before it needs rethinking.
The folders
repo/
├── apps/
│ ├── web/ SvelteKit (frontend + BFF routes)
│ └── api/ Fastify (REST API, sessions, jobs)
├── packages/
│ ├── schemas/ Zod schemas, shared types
│ └── ui/ Svelte components used in 1+ app
├── docker-compose.yml
├── package.json npm workspaces
└── tsconfig.base.json
That's it. Two apps, two packages. apps/* are deployable units; packages/* are imported by the apps and never deployed directly. The split rarely needs to be more elaborate than that, and when it does it's usually a sign you should be making a second repo, not a third package.
Why schemas is its own package
The single highest-leverage decision in this layout is putting the Zod schemas in a shared package and importing them from both ends. The frontend validates form inputs against the same shape the backend validates request bodies against. When the schema changes, both sides break in TypeScript at the same time. There is no drift, because there is no copy.
// packages/schemas/src/post.ts
import { z } from 'zod';
export const postCreateSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
tags: z.array(z.string()).max(10),
});
export type PostCreate = z.infer<typeof postCreateSchema>;
Both apps import postCreateSchema. The web app pipes it into a form library; the API pipes it into a Fastify route schema. One source of truth, two consumers, zero ceremony.
How the apps talk
SvelteKit's server endpoints (the +server.ts files) act as a thin BFF — they handle session reads, talk to the Fastify API over HTTP on the internal Docker network, and return JSON to the browser. The browser never talks to Fastify directly in production. This buys two things:
- Session locality. The session lives in one place — the SvelteKit server. The Fastify API is stateless and authenticates requests via a signed internal header.
- A clean public surface. The browser sees one origin. CORS is not a problem you have to solve, because there is no cross-origin call.
Docker Compose for local dev
Three services: web, api, mongo. Both apps mount their source as a volume and run in dev mode with file watching. The compose file is the single source of truth for local environment variables — there is no .env in the app directories that contradicts it.
services:
web:
build: ./apps/web
ports: ['5173:5173']
environment:
API_URL: http://api:3000
SESSION_SECRET: dev-only-not-real
depends_on: [api]
api:
build: ./apps/api
environment:
MONGO_URL: mongodb://mongo:27017/app
depends_on: [mongo]
mongo:
image: mongo:7
volumes: ['./.data/mongo:/data/db']
One docker compose up and you have a working stack. New developer onboarding is a clone, an npm install, and that command. I have measured this against more elaborate setups and the elaborate ones never win.
The TypeScript story
Project references on, composite: true in every package, a single tsconfig.base.json at the root with the strict flags turned on. The apps extend the base and add their own paths. Editor performance is fine in this layout up to maybe a dozen packages — past that you start wanting Turborepo, but I haven't needed it yet on anything I've shipped.
What I would do differently next time
Honestly, very little. The one thing I'd consider is splitting the schemas package into schemas-runtime (the Zod objects) and schemas-types (just the inferred TS types) so that the frontend can import only the types and ship a smaller bundle. I haven't actually had a bundle-size problem from this, though, so I haven't done it. That last bit is the whole posture: do less, until something hurts.
The boring layout earns its keep by being the layout I don't have to think about. Every minute I'm not thinking about the folders is a minute I'm thinking about the product.