Architecture & Technology
About the case study
A solo-built Turborepo with 40+ shipped features across web, native mobile, and PWA runs on a handful of load-bearing decisions. Published 2026-04-15.
Architecture overview
The repo is a Turborepo workspace on top of pnpm. The split between apps and packages is the spine: apps are deployed surfaces, packages are libraries that two or more apps share.
Applications
- app: the authenticated product where users live, Next.js 16 on the App Router, 112 API routes
- api: the webhook + cron + admin surface, deployed separately from app
- web: the marketing site
- mobile: the iOS/Android client (Expo SDK 54)
- email: the React Email preview app
- docs, storybook, studio: internal tooling
- workflows: background jobs deployed as a separate service
Packages, grouped by what they do
- Foundation: typescript-config, next-config, database (Prisma), design-system (shadcn/ui-based)
- Product domains: ai, chatbot, email, payments, notifications
- Cross-cutting: auth (Clerk), feature-flags, internationalization, rate-limit, security, storage, webhooks (Svix-based outbound), observability (Sentry + Logtail)
- Operational: analytics, seo, workflow-utils
Decisions that held up
Splitting apps/api from apps/app
apps/api is its own Next.js deployment on its own port (3002 in dev). It serves only inbound webhooks (Clerk auth, Stripe payments, Resend email), cron endpoints (keep-alive, drip emails), and a thin admin API. The user-facing routes live in apps/app, which has 112 API routes for product features.
The reasons are practical: webhooks need signature verification and no Clerk session; product routes need exactly the opposite. BotID protection lives on api, not on app. A flaky Stripe handler should not be able to take down the user-facing app. Webhook load is bursty and external; product load is smooth and authenticated. The cost is one extra Next.js app to deploy and a slightly more complex local-dev story. The benefit is that I never had to think about "should this middleware run on the webhook" or "what if this cron starves the product."
Vercel AI Gateway as a universal chokepoint
Every AI request in the system routes through Vercel AI Gateway. The config in packages/ai/lib/config/model-tiers.ts declares enabled: true and there is no opt-out. The gateway is the single most consequential decision in the AI surface: one observability surface, one rate-limit boundary, one cost-tracking layer, one place to swap providers underneath. When Anthropic releases a faster model, the swap is a config change. When I want to know what the system spent on AI yesterday, the answer is one screen. When I add a new AI feature, none of the observability work is fresh.
Single Postgres + single Prisma schema
The data layer is one Vercel Postgres instance with one Prisma schema covering everything: user data, marketing consent, subscriptions, accomplishments, reflections, goals, contacts, notification events. No separate marketing DB, no separate auth DB, no separate analytics DB. The reason I did not split is that the data needs to join. Marketing consent correlates to billing status. Onboarding completion correlates to reflection cadence. Notification eligibility correlates to feature flag state. Splitting the database would have produced sync work for no real isolation gain.
The four-layer component hierarchy
Components live in one of four places, codified in the constitution:
- packages/design-system/components/: UI primitives, no business logic (shadcn/ui base + form wrappers)
- apps/app/components/{domain}/: used across two or more features
- apps/app/app/(authenticated)/_components/: layout shell (sidebar, header, nav)
- apps/app/app/(authenticated)/{feature}/_components/: feature-route components
The decision tree fits on one page: new component, single question: where does this live? There is a promotion path when a component graduates from feature-route to app-shared to design system. The hierarchy makes you wait until the third use, which is when the abstraction is actually safe. Most components stay in _components folders, which is exactly where they should be.
Decisions I'd reconsider
Expo for mobile
The mobile app is Expo SDK 54. I would not use Expo if I were starting today. Expo's promise is "write JavaScript, ship to two platforms" — the promise is real, but the cost is also real: when something breaks, the surface area of "is this a JS bug, an Expo SDK bug, a Metro bundler bug, an iOS-Expo-config issue, or a real native bug" is wide. With native, the surface is narrower, the tools are sharper, and the debugger does not lie to you. For a solo product where iOS is the primary surface (which is what olllo's mobile ended up being once usage data showed where the users actually were), I would write Swift.
The half-built analytics abstraction
packages/analytics is named for swappability. Call sites import analytics from @repo/analytics rather than from posthog-js. The boundary is at the import path. The methods underneath are not abstracted — analytics.capture(), analytics.identify(), analytics.flush() are PostHog method names. The package re-exports posthog-js directly with a renamed identifier. Now that I am moving off PostHog, the incompleteness is visible.
Two paths forward: build the homegrown analytics layer to match PostHog's method API (the package swap stays at one file), or update all the call sites to a new API (the package shape changes too). The lesson is that a half-abstraction is worse than no abstraction: it makes you think the swap is cheaper than it is. A package boundary named for swappability looks like an interface; it isn't, until the methods underneath are wrapped too. I would either go all the way (a generic capture/identify interface, PostHog wrapped inside it) or not at all.
Bought vs built
Things I bought
- Clerk (auth, sessions, social login, org management, webhooks)
- Stripe (subscriptions, billing, customer portal)
- Resend (transactional + marketing email)
- Knock (multi-channel notification routing, user preferences)
- Vercel (hosting, edge runtime, AI Gateway)
- Anthropic via the gateway (Claude models)
- Sentry (error tracking)
- Logtail (structured logs)
- Upstash Redis (rate limiting)
- Sanity (marketing copy CMS)
Things I built
- The reflection multi-agent flow
- The accomplishment refinement chat
- The waitlist + invite + free-forever-grant system
- The marketing email consent + tokenized unsubscribe
- The voice capture pipeline (audio captured, transcribed, then extracted into a STAR-format entry)
The split is roughly: bought every commodity, built every product surface. Auth, payments, and email delivery are all commodities. Reflection conversation is the product, and a custom build was the only way it could have worked.
Vendor decisions in hindsight
PostHog for a homegrown analytics layer. The product was harder to use and integrate than expected, especially around chart customization and Slack alerts. I have started building lightweight in-app analytics tailored to the metrics olllo actually needed, and it has been surprisingly cheap. Whether the homegrown version stays simple as I add use cases is the open question.
Knock turned out to be narrower than I bought it for. I picked Knock for multi-channel notification routing across email, in-app, and push, with user preferences and a send-history API. In practice I used it only for schedule management. Keeping email styling consistent across olllo meant rendering templates inside my own @repo/email package and sending them through Resend, so the real flow is: Knock fires a scheduled webhook, apps/api listens, the email package renders, Resend delivers. With hindsight, I would consider replacing the Knock dependency with a cron and my own scheduling, since the value I extracted was the schedule, not the multi-channel send.
The rest of the bought stack paid off cleanly. Clerk, Stripe, Resend, Vercel + AI Gateway, Sentry, Logtail, Upstash, and Sanity all behaved as advertised and saved meaningful build time on day one.
Why solo
The honest answer to "why solo" is not "I prefer working alone." I started olllo with a co-builder, and after about a month they became unavailable. I had two options at that point: pause and find another co-builder, or absorb the second seat and keep going. The architecture decisions documented above are mostly downstream of choosing to keep going. A co-built version of olllo would probably look different. Some of the structural discipline I imposed on myself (the constitution, the spec gates) is downstream of that choice.