The design system I didn't buy


My architecture doc said Domi’s UI layer was shadcn/ui. I found out this week that it isn’t, and hasn’t been for a long time. No Radix dependency. No components.json. No components/ui/ directory. Just 42 components I wrote by hand, using the shadcn idiomcva + clsx + tailwind-merge — without the shadcn library.

Nobody noticed because nothing broke. That’s the interesting part, and it’s a decent lens on the whole front end: a pile of choices that each looked small, compounded into something coherent, and quietly diverged from what I’d written down.

Here’s what’s actually in there.

The stack, as it exists

Next.js 15 App Router, React 19, on Vercel. 61 pages, 252 component files, 52 files exporting server actions.

No tRPC. The original spec called for it. It was never built. Mutations run through shared, Zod-validated modules in packages/shared/src/*/mutations.ts, called from server actions, chat tool applicators, cron jobs, and the ingestion pipeline. One function, four callers, no API layer in between.

Tailwind v4 with CSS-first config — a 235-line globals.css that defines the entire visual language as custom properties: --color-background, --color-surface, --color-surface-elevated, --color-muted-foreground, --color-primary, --color-danger, and so on. Dark mode is a @custom-variant on a .dark class rather than the media query, so the theme picker can offer light/dark/system instead of obeying the OS.

next-intl v4, English and Quebec French, /en and /fr routing. 2,556 translation keys in each locale, kept in sync by a CI gate that fails the build if they drift.

The rest, where it earns its place: the Vercel AI SDK for chat streaming, TipTap for rich-text notes, d3-force/drag/zoom for the knowledge-graph view, Sentry via OpenTelemetry, Auth.js for the front door.

The four decisions that shaped it

1. Server Components and Server Actions instead of an API layer

Why: Domi is one Next.js app. A tRPC layer would have meant defining every capability twice — once as a procedure, once as the thing the procedure calls — and then keeping them honest. Server actions let a page call a mutation module directly.

What it bought me. No client/server type drift, because there’s no wire contract to drift. A capability is one Zod-schema’d function, and the chat surface and the MCP server both consume the same factory — when I add a tool for the AI, the API for external assistants comes free. That’s not a small thing: 35 MCP tools exist today and I never wrote an endpoint for any of them.

What it cost me. Three things, all real:

The moment a non-web client appeared, the free lunch ended. Building Domi’s mobile app meant standing up an actual REST surface — /api/mobile/* — with its own auth profile, its own typed OpenAPI contract, and its own sync protocol. Server actions don’t cross the app boundary, so the second consumer paid the full price the first one avoided.

Server actions are awkward to test in isolation. There’s no procedure to call in a unit test. My real-database integration suite carries weight that a typed RPC layer would have let unit tests carry, and it runs against ephemeral Neon branches sharded four ways to stay under five minutes.

And there’s a footgun with teeth: a file marked "use server" may only export async functions. Export a const from one and TypeScript is perfectly happy — then the production build fails. I’ve hit it more than once.

2. Tailwind v4 with hand-rolled components

This is the shadcn thing. I started intending to use it, absorbed the pattern — variants via class-variance-authority, class merging via tailwind-merge, semantic tokens instead of raw colors — and never actually installed the library.

What it bought me. No component dependency to upgrade, patch, or fight. When I moved Domi off its original Matrix-green-on-black look to something calmer, it was one CSS file. Every surface followed, because nothing hardcodes a color — components reference bg-surface and text-muted-foreground, never bg-zinc-900. The theme picker was nearly free for the same reason.

It also keeps the bundle honest. No Radix primitives I use two of.

What it cost me. I own accessibility. Radix hands you keyboard navigation, focus management and ARIA wiring for a dropdown. Hand-rolled means I wire it, and the enforcement is discipline plus a shared FOCUS_RING constant rather than a library that got it right for me. Domi targets WCAG 2.2 AA. Every dialog, every menu, every picker is a thing I had to think about individually, and I’d bet money there are gaps.

There’s no Storybook and no visual regression testing. 42 components, and CI cannot see a single one of them. My typecheck, lint, and 2,800 tests all pass while a layout is broken — which is exactly what happened this week, twice. Both times the thing that caught it was me opening the app.

And the drift: for months my own architecture doc named a library I wasn’t using. Documentation that isn’t executable rots silently. The lesson generalizes — the fix isn’t to write better docs, it’s to make the truth checkable. I now have a CI gate that derives which AI evals must exist from the model catalog, precisely so a config edit can’t outrun its documentation.

3. Bilingual from the first commit

Every user-facing string in Domi exists in English and French. Not “planned for later” — enforced from the start by a gate that counts keys in both locales and fails when they disagree.

What it bought me. Quebec is the market I’m building for first; my own household runs in both languages. Retrofitting i18n into a mature app is a miserable multi-week project, and I’ve never seen anyone do it happily. Doing it from day one made it a habit instead of a migration.

What it cost me. Everything is 2× the copy. A quick UI experiment is never quick — a throwaway label still needs a French counterpart, or CI stops me. Copy lives in JSON files away from the components that use it, which makes reading a component slightly harder and writing one noticeably slower. I think it’s worth it. I also think anyone who tells you i18n is cheap has only ever done it in a demo.

4. Living on recent releases

React 19, Next 15, Tailwind v4, AI SDK v7. And next-auth@5.0.0-beta.32 — a beta, in production, handling my authentication.

What it bought me. Server Components are the reason the API-layer decision works at all. Tailwind v4’s CSS-first config is what makes the token system a single file. These aren’t incidental version numbers; the architecture depends on them.

What it cost me. Upgrades are load-bearing and they bite. The AI SDK v7 bump took chat down completely in production — the types and the build accepted a shape the runtime rejected, so nothing caught it until real traffic did. That produced a rule I now follow without exception: any change to a model-call shape gets one live call before it merges. Not a typecheck. A real request.

What I’d tell someone starting the same thing

Server Actions are excellent until you have a second client. If you know a mobile app is coming, the calculus changes — you’ll build the typed API eventually, and building it second means building it twice.

Adopting a library’s patterns without the library is a real option, and cheaper than it sounds — but be honest that you’ve bought the accessibility work, not avoided it.

Semantic tokens are the highest-leverage 200 lines in the codebase. Two complete visual redesigns, both one file.

Your CI can be entirely green while the app looks broken. Everything I have tests behavior. Nothing tests appearance. Both of this week’s genuine misses were found by using the product, not by the suite — and I’d rather say that plainly than pretend the green checkmarks mean more than they do.

The front end isn’t the interesting part of Domi. The interesting part is a knowledge graph of a household that predicts what needs doing. But the front end is the part I touch every day, and the part where a wrong small decision compounds fastest.

Mostly I got lucky. The one place I didn’t — a design system I’d documented but never installed — cost me nothing except the mild embarrassment of finding out in public.