When we started building Jottings, we faced a classic decision: monorepo or polyrepo?
Most startups go polyrepo. It feels cleaner. Separate repos for separate concerns. But after shipping Jottings, I'm convinced the monorepo was the right call. And I want to walk you through why.
Why We Didn't Go Polyrepo
I get the appeal of the polyrepo approach:
- Frontend team owns the frontend repo.
- Backend team owns the backend repo.
- Deployment pipelines are independent.
Except we're a tiny team. And every "independent" pipeline adds complexity.
The polyrepo costs we'd have paid:
- Keeping shared types in sync across repos (or publishing packages).
- Coordinating releases between frontend and backend.
- Running tests across multiple repos to verify the integration works.
- Managing deployment ordering when API changes require frontend changes.
For a small team, this overhead is crushing.
The Monorepo Tradeoff
A monorepo isn't perfect. But for Jottings, the wins outweighed the costs:
Single source of truth for types. The backend and frontend share TypeScript types from lambda/shared/types/. When the API response structure changes, the frontend automatically uses the new types. No more "update the client after shipping the server" mistakes.
Atomic commits across services. If a feature requires both a frontend component and a backend endpoint, it's one commit. The entire feature is in git history as a unit. This matters more than you'd think when debugging issues 6 months later.
Unified development workflow. npm install installs dependencies for everything. npm run dev starts the frontend and backend. New developers get the entire system running in 10 minutes.
No package versioning headaches. We don't publish shared packages. We don't manage version incompatibilities between frontend and backend. We just update types and deploy everything together.
The Directory Structure
Here's how Jottings is organized:
jottings-dashboard/
├── frontend/ # SvelteKit dashboard
│ ├── src/
│ │ ├── lib/
│ │ ├── routes/
│ │ └── styles/
│ └── package.json
│
├── landing/ # Static landing page
│ ├── src/
│ ├── content/
│ ├── build.js # Custom build script
│ └── package.json
│
├── lambda/ # AWS Lambda (API + Build processor)
│ ├── api/
│ │ ├── routes/
│ │ ├── middleware/
│ │ └── utils/
│ ├── build/
│ │ ├── processor.ts # Site generation engine
│ │ ├── markdown-parser.ts
│ │ └── tag-utils.ts
│ ├── shared/
│ │ ├── types/ # Shared TypeScript types
│ │ └── constants/
│ ├── triggers/
│ │ └── cognito.ts # Auth hooks
│ └── package.json
│
├── workers/ # Cloudflare Workers
│ └── sites-router/ # Subdomain routing (*.jottings.me)
│
├── templates/ # HTML templates for static sites
│ └── default/
│
├── docs/ # Architecture documentation
│ ├── api.md
│ ├── deployment.md
│ └── monitoring.md
│
└── package.json (root)
Why This Structure Works
Separation by deployment target, not by function. We don't separate "UI" from "API" at the root level. Instead, we separate by how things are deployed:
- frontend/ runs on Cloudflare Pages
- landing/ is a static site on Cloudflare Pages
- lambda/ runs on AWS Lambda
- workers/ runs on Cloudflare's edge network
This means when you want to understand how a feature works end-to-end, you follow the data flow, not directory boundaries.
Shared types live in lambda/shared/types/. The API response types are defined once. The frontend imports them. No duplication, no sync issues.
// lambda/shared/types/site.ts
export interface Site {
id: string;
subdomain: string;
title: string;
createdAt: string;
}
// frontend/src/lib/api.ts
import type { Site } from '../../../lambda/shared/types/site'
const response = await fetch('/api/v1/sites')
const { data: { sites } }: ApiResponse<Site[]> = await response.json()
lambda/shared/ also holds constants. HTTP status codes, API error messages, validation schemas—anything the backend and frontend both need.
The Shared Code Question
The monorepo lets us share code, but we're intentional about what we share:
Share: Type definitions. Interfaces should be in shared/types/.
Share: Constants. Error codes, API paths, validation rules in shared/constants/.
Don't share: Business logic. The backend processes data differently than the frontend displays it. They shouldn't share mutation logic.
Don't share: Styling. Each service has its own styling. Cloudflare Pages doesn't need Tailwind. Lambda doesn't need CSS at all.
This keeps services loosely coupled even though they're in the same repo.
Deployment Stays Independent
Here's the key insight: monorepo doesn't mean monolithic deployment.
- Frontend deploys automatically when you push to
main(Cloudflare Pages Git integration). - Backend deploys via Serverless Framework (separate command, separate pipeline).
- Workers deploy separately via Wrangler.
- Landing page has its own build script and deployment.
Each service can be deployed independently. But they all come from the same git history. The best of both worlds.
When Monorepos Fail
I'll be honest: monorepos have real costs at scale.
If Jottings becomes a team of 20 people, we might split into separate repos. At that point:
- Different teams move at different velocities.
- Dependency management becomes painful.
- Build times grow (you rebuild everything even if one service changed).
But that's a good problem to have.
The Verdict
The monorepo worked for Jottings because:
- We're small enough that coordination is cheap.
- Type safety across services is worth more than organizational separation.
- Every line of code exists for a reason. Atomic commits matter.
- We deploy often enough that dependency hell isn't a concern.
If you're building a new platform, I'd lean toward monorepo first. You can always split it later. The hard part is adding infrastructure for sharing types and constants after the fact.
Start simple. Keep code close. Deploy often.
Want to see the actual code? Jottings is open source. Check out the repo and explore how everything fits together. Building a monorepo might be the right move for your next project too.