The building blocks
Every block is its own workspace package under packages/. The rule that keeps them
reusable: packages are generic, the app owns the domain. A block knows how to
store files, send email, run jobs — it knows nothing about projects or tasks.
Dependency graph
Note the direction: the app depends on the blocks; no block ever depends on the app. The database block sits at the center because four others persist through it.
Two patterns every block follows
The browser stub
Server blocks must never leak into the client bundle — they carry credentials and
native dependencies. Every server block therefore ships a stub through the browser
condition of its exports map. If client code imports the package by mistake, it
throws immediately with a message that points the right way:
/**
* Browser-side stub for @repo/storage. The real implementation talks to
* MinIO / Azure Blob with credentials — server-only. Any client-side import
* resolves to this stub, which throws if called. Server code hits the real
* implementation because Vite picks the "node" export condition in SSR.
*/
function serverOnly(): never {
throw new Error(
"@repo/storage is server-only. Call it from a createServerFn handler, an API route, or a job.",
);
}
export const extForImage = serverOnly;
export const uploadKey = serverOnly;
export const putObject = serverOnly;
export const getObject = serverOnly;
export const removeObject = serverOnly;
export const fileUrl = serverOnly;String-free UI
UI packages do one of two things: handle layout, or render one specific
component. They never contain business logic and they never contain user-facing
strings — every label, empty state and date locale arrives as a prop. The
consumer owns all text through its i18n layer (the example uses Paraglide JS,
with Swedish as the base locale and English alongside). That is what makes a block
reusable across products and languages: AdminShell, DataTable, EditSheet and
OutboxLog render in whatever language the consumer hands them.
Graceful degradation
Optional infrastructure turns itself on through the environment. Without
HATCHET_CLIENT_TOKEN, enqueues log-and-skip. Without SMTP_HOST, email stays in
the outbox. Without an AI key, callers fall back to their stubs. The app always
boots — a missing optional service degrades a feature, never the whole product.
The blocks at a glance
| Block | One-liner | Django parallel |
|---|---|---|
| db | Drizzle client + composable schema fragments | ORM + migrations |
| auth | Better Auth + the session choke point | django.contrib.auth |
| storage | One file API, MinIO or Azure Blob behind it | django-storages |
| mailer | Outbox-always email, SMTP when configured | send_mail |
| jobs | Hatchet wiring with an env kill-switch | Celery |
| ai | Env-driven LLM adapter factory | — |
| audit | Explicit change history that outlives its subjects | signals + auditlog |
| config | Runtime settings with a TTL cache | constance |
| admin-ui | Changelist, selection bar, bulk-edit sheet, admin shell | the admin |
| import-export | Two-phase CSV import (plan → preview → confirm) | django-import-export |
| debug | SWEREF 99 transforms + control points + Sweden map | GeoDjango (parts) |
| metrics | prom-client registry + HTTP histogram | django-prometheus |
| enterprise sign-in | LDAP directory + OIDC SSO on the auth core | allauth + auth-ldap |
| realtime | Server-push over Centrifugo (WebSocket) | Channels / Daphne |
| cache | Valkey JSON cache + shared rate-limit store | the cache framework |
| ui, env, ldap, edge-sso | Primitives and enterprise auth helpers | — |