Mercury

By Deepesh Kalura · August 3, 2026

Rebuilding Mercury: what broke and how we fixed it

A brutally honest post-mortem of the Mercury web rebuild — three apps, one design system, and the parts we still regret.

For the last six months, Mercury was a 79-line React form. One input for the email, one input for the API key, a button, and a <pre> with the JSON response. That was the entire product surface. There was no marketing site. There was no console. The docs said “sign up at the portal” — there was no portal. The README pointed at a webhook handler that didn’t exist. A developer who heard about us from a friend could verify exactly one email, then bounce off the page.

We knew this was wrong. We shipped it anyway, because the API was what mattered. Then we started losing deals to vendors with shinier websites. Then a customer asked for “bulk verification” and we pointed at a CLI script we hadn’t written. Then an investor asked for a SOC 2 report and we sent them a placeholder PDF.

This post is the rebuild. It’s long, it’s specific, and it’s honest about the parts we still don’t like. If you’re a small team shipping a developer tool and you recognise the shape of the problem, hopefully some of this saves you a quarter.

Where we were

Before this rebuild, Mercury was:

  • A Hono-on-Cloudflare-Workers API that genuinely works. Six verification signals. A KV cache that survived a Reddit hug-of-death. Real pipeline: format → disposable → role → mx → catch-all → reputation → smtp_probe, short-circuiting on hard fail.
  • An OpenAPI spec generated from zod schemas. The single source of truth, per AGENTS.md. pnpm -F api openapi writes apps/api/openapi.json; apps/docs consumes it.
  • Better-auth declared in dependencies, never wired.
  • One React form that called /v1/verify and dumped the result.

The API was real. The product wasn’t. We had no way to issue an API key, no team management, no billing UI, no onboarding email, no pricing page, no blog, no public status page, no SOC 2, no white-label JSON for Enterprise. The “Free tier” was real because the middleware enforced it; everything else existed only in our Slack and in scattered Notion docs.

The bug list

We wrote down everything wrong before we started fixing anything. Here is the honest version, not the marketing version.

  1. Better-auth was never wired. Listed in package.json, imported nowhere. The portal needed a session, and we didn’t have one.
  2. API endpoints referenced by docs didn’t exist. The reference page mentioned /v1/keys, /v1/team/invites, webhooks CRUD, custom blocklist CRUD. None of it existed in apps/api/src/routes/. A developer reading the docs got a 404.
  3. Pricing was mentioned in docs, never on a page. It was a paragraph in reference.mdx and a table in the README. That is not a pricing page.
  4. There was no signup. The README said “create a free team” with no link. We had to DM people a Dodo checkout URL.
  5. Status updates lived on a Twitter account. For a deliverability product. In 2026.
  6. Docs were three pages. Introduction, quickstart, reference. No tutorials. No concepts. No webhook guide.
  7. The console form was the only console. One page. No team view. No bulk. No blocklist. No webhooks.
  8. Design system was a Figma file no engineer opened. “Midnight Blue / Electric Blue / glass.” None of it was in the code.
  9. AGENTS.md said TDD only. The API was mostly TDD. The console had no tests at all.

That list, written down on a whiteboard, took 90 minutes to compile. We taped it to the wall and stared at it for two days before we touched any code.

What we chose

The rebuild split into three apps and one design package:

  • apps/marketing — Astro, SSG, SEO-first. /, /pricing, /blog, /legal/*, /security. Markdown-driven blog with RSS + JSON-LD + sitemap + IndexNow.
  • apps/console — Vite + React 19 SPA, the same auth-shaped router for everyone, but optimised for dashboards.
  • apps/docs — Astro + Starlight, now inheriting the same tokens.
  • packages/design — the only place colors, radius, space, motion live. CSS variables for Astro; React primitives for the console.

One design system, three rendering modes. The hand-rolled primitives — Button, Input, Card, StatusChip, ThreatIndicator, DataTable, AppShell, ApiKeyField — sit on top of the tokens. Eight components, fifty-seven tests, no Tailwind.

Why not one app?

We considered it. A single Vite + React SPA can serve both anonymous visitors and authenticated dashboards if you’re careful. But:

  • Marketing pages need SSG and Lighthouse scores in the 90s. SPAs can’t deliver that without heroic effort.
  • The console’s auth-protected routes assume a session. Putting those behind the same router as the public homepage complicates cache control.
  • Three apps means three wrangler.jsonc files and three Cloudflare Pages projects. That’s the only meaningful cost; it’s a one-time payment.

The split was the right call. We can fail over the marketing site independently when the console has an outage, and vice versa.

Why not shadcn?

We tried. Three components in, we stopped. The Midnight Blue / Electric Blue / glass aesthetic with sharp 1 px borders and zero drop-shadows fights shadcn’s defaults at every level. We’d have ended up overriding every component class. Eight hand-rolled primitives took less code and look better.

Why not Tailwind?

Design systems are contracts. Tailwind utility soup makes the contract implicit — a Button class becomes a list of utility strings, and the only place the design tokens are referenced is inside Tailwind’s config file. CSS Modules + design tokens (PostCSS-flavoured) keeps the tokens as the canonical source and makes the contract grep-able.

Trade-offs we accepted

The console bundle is 344 KB

Uncompressed JS for the console, gzipped 108 KB. We could shave another 30% by lazy-loading the bulk, webhooks, and blocklist pages, but the team that needs them needs them all at once. We chose predictability over cleverness.

Several console pages still use stub data

/app/keys, /app/billing, /app/team, /app/bulk, /app/webhooks, and /app/blocklist render against hard-coded fixtures in this PR. The API endpoints exist; the UI plumbing is wired. The next PR replaces the fixtures with real useQuery calls. We did it this way because the API surface needed to settle first — every fixture re-shape would have caused a UI re-shape, and we’d have been bouncing between the two all week.

better-auth still isn’t wired end-to-end

The cookie session middleware isn’t mounted on the API worker yet. The console’s auth flow signs in against /api/auth/* which is currently a 404 stub. The API key auth path works. We will land session auth in a follow-up PR; the migration is additive (the API-key middleware stays).

No bulk upload of disposable domains

The disposables list is curated by our cron job from Spamhaus + community sources. There’s no portal UI to add domains yet. That’s a feature, not a bug — a single admin email is faster than a portal form for us today. The apps/scripts cron pulls it daily.

What we kept (and why)

  • zod as the single source of truth. Every request/response shape comes from packages/schemas. The OpenAPI spec is generated, not hand-written. Every new endpoint that disagreed with the spec got rejected in CI.
  • The seven-stage pipeline. format → disposable → role → mx → catch-all → reputation → smtp_probe — never reordered, never removed.
  • Verdict semantics. Never binary. valid | invalid | risky | unknown. A verdict of unknown is not a failure; it’s an honest “we couldn’t conclude.”
  • KV cache. 1 h default TTL. Free tier caches more aggressively.
  • Dodo Payments. Standard Webhooks verification on /webhooks/dodo. mr_live_* prefix, not ev_*.

What still sucks

Honestly:

  • No public status page yet. It’s on the roadmap. apps/scripts already emits usage telemetry to KV; we need to wire a status page on top.
  • No mobile app. The console is web-only. Our users are operators; they don’t ask for mobile.
  • No churn alerts. We have usage data; we don’t have retention analytics. Soon.
  • Documentation is still shallow. Three pages became nine. Tutorials will come.

What’s next

  • Public status page backed by KV.
  • Churn alerts (rule: 14-day no-verify for Pro/Team = outreach).
  • Webhook delivery UI with replay.
  • White-label JSON for Enterprise.
  • SOC 2 Type II.

If you take one thing from this post

The reason small developer tools die isn’t the API. It’s the perimeter. The code you can run is the product. If a developer can’t sign up, can’t see pricing, can’t read docs, can’t get an API key, and can’t see who’s on their team — the API doesn’t matter. They churn before they curl.

Write the perimeter first. Then ship the API. We did it backwards, and we paid for it with deals we should have won.

Want to see the result? Create a free Mercury team. Or read the quickstart if you’d rather code first.