There is no single correct architecture, but there is a default worth defending — one that gets out of the way on small projects and holds up on large ones. This is the shape we reach for on most builds, and the reasoning behind each part of it. None of it is exotic. The value is in how the pieces fit, and in the boundaries between them.
One repository, clear packages
We keep the whole system in one typed monorepo — the site, the admin, the API, and the shared packages that sit under all of them. A single git root means one place to reason about the system, atomic changes across the stack, and shared types that cannot drift because everyone imports the same source.
apps/
site/ — the public website (Next.js, mostly Server Components)
admin/ — the internal CMS + Business OS surfaces
api/ — the service layer and seed scripts
packages/
types/ — canonical domain types (the single source of truth)
ui/ — the shared component library and design tokens
validation/ — runtime schemas that mirror the types
database/ — the Prisma client and schemaThe service seam
The most important boundary in the whole architecture is the one between the interface and the data. Pages never fetch from a database or call an API directly. They call a service — a small, typed function whose signature is a promise the rest of the app depends on and whose body is free to change.
// The page composes; the service resolves. The page has no idea whether
// getArticles() reads a mock array, a cache, or a live API — and it never will.
export default async function JournalPage() {
const [featured, articles, categories] = await Promise.all([
getFeaturedArticle(),
getArticles(),
getJournalCategories(),
]);
return <JournalExplorer featured={featured} articles={articles} categories={categories} />;
}This is what lets us build the interface before the backend exists, and swap a mock for a real API later without touching a single component. It is the same discipline that made this very Engineering Journal renderable as static data long before it was wired to the content spine behind it.
Server Components do the heavy lifting
The cheapest kilobyte of JavaScript is the one never shipped to the browser. So we render on the server by default and add client interactivity only where a user actually touches the page — a filter, a search box, a reading-progress bar. Everything else ships as HTML: fast, indexable, and cheap to run.
- The listing is a Server Component; only the filter and search are client islands.
- The article body renders on the server; the table of contents is the only interactive piece.
- Data fetching happens once, on the server, close to the source — never waterfalled from the browser.
One source of truth, end to end
A single type describes a row in Postgres, the API payload, and the props of the component that renders it. We define it once in the shared @iwc/types package and let inference carry it the rest of the way. When a column changes, the type changes, and every consumer that no longer agrees lights up in the editor before it ever reaches production.
| Seam | Who owns the shape |
|---|---|
| Database → server | The generated Prisma client |
| Server → client | The shared @iwc/types package |
| Client → screen | The same shared type, narrowed with a utility |
Runtime validation at the edges
Types vanish at runtime, so at every boundary where untrusted data crosses in — a form submission, an API response, a seed payload — we validate against a schema that mirrors the type. Inside the system we trust the types; at the edges we verify. That combination is what lets the interior stay clean without being naive about the outside world.
This is the same architecture underneath everything we build, from client sites to Pulse and Business OS. It is not the newest possible stack — it is the one whose boundaries we trust, which is a different and more valuable property.
- #Architecture
- #Monorepo
- #Next.js
- #TypeScript
- #Prisma