All posts
Engineering2026-06-10

We Audited Our Own Site: SEO, Core Web Vitals & the Tools We Used

We build a speech-to-text product, not a content farm. But a fast product nobody can find is a tree falling in an empty forest. So we pointed our own benchmarking tools at dictatorflow.com, found the real bottleneck, and fixed it. The surprise: speed was never the problem.

The short version
  • > The homepage already passed Core Web Vitals: LCP 1.08s, CLS 0.0138, FCP 932ms.
  • > The real problem was SEO: as a single-page app, all 25 routes shipped the same title and zero structured data.
  • > A link checker could only crawl 2 pages, because the static HTML had no links in it.
  • > We fixed it with per-route metadata, JSON-LD, build-time prerendering, and route code-splitting.
  • > Then we built a tool so it can never silently regress.

Measure first, opinions later

We never start an optimization by guessing. We start by measuring with the same tools we use on everything else: a Core Web Vitals harness driven by headless Chrome, a broken-link crawler, and a JavaScript error checker. Three commands, real numbers.

webvitals https://dictatorflow.com --runs=3
blc       https://dictatorflow.com --depth=2
seoaudit  https://dictatorflow.com --sitemap

The vitals came back clean. That matters, because the easiest mistake in performance work is optimizing the thing that is already fast. We were tempted to start shaving JavaScript. The data told us not to.

Homepage, before any changes
1.08s
LCP
0.014
CLS
932ms
FCP
35ms
TTFB

The link checker found the real bug

Our broken-link crawler reported zero broken links. Good news, except it had only crawled two pages. That is the tell. The site is a React single-page app: the server sends a near-empty HTML shell, and JavaScript renders everything after the fact. A crawler that reads HTML the way a search engine or a Slack link preview does sees no content and no links to follow.

We confirmed it with our audit tool. In the raw HTML, every single page returned the identical title and description:

RouteTitle a crawler sawJSON-LD
/Talk Fast. Text Faster.none
/blog/...deepgramTalk Fast. Text Faster.none
/docsTalk Fast. Text Faster.none

Fourteen pages, one title, zero structured data. Our best comparison articles and our API docs were, as far as a link preview was concerned, indistinguishable from the front page.

Fix 1: every page owns its own head

We are on React 19, which hoists <title>, <meta>, and <link> tags into the document head no matter where you render them. So we wrote one small <Seo> component, no extra dependency, and gave every page real metadata: a unique title, a real description, a canonical URL, and Open Graph and Twitter cards.

Blog posts also emit JSON-LD structured dataBlogPosting and BreadcrumbList — and the homepage emits Organization, WebSite, SoftwareApplication, and an FAQPage. That is the difference between a blue link and a rich result with breadcrumbs, ratings, and an expandable FAQ.

Fix 2: prerender so the JS-less visitors get the goods

Per-route tags only help readers that run JavaScript. Googlebot does. The X, Slack, Facebook, and LinkedIn link unfurlers do not. So a JS-only fix still ships a blank preview to every social share.

The fix is to bake the rendered HTML at build time. After vite build, a small script reads our sitemap, loads each route in headless Chrome, lets the <Seo> component populate the head, and writes the fully-rendered HTML back to disk per route.

bun run build
  -> generate-sitemap   (routes + posts, always in sync)
  -> vite build         (code-split bundles)
  -> prerender          (25/25 routes -> static HTML)

One subtle trap we hit and fixed: the prerenderer writes each route’s HTML as it goes, so it must serve every navigation the original clean shell, never a shell already polluted by a previous route. Get that wrong and every page inherits the last page’s tags. Measure the output, not the intent.

Fix 3: stop shipping the checkout to the homepage

While we were in there, the resource waterfall showed Stripe’s 232KB script loading on the landing page. Nobody checks out from the landing page. It loaded because the dashboard module was imported eagerly, and the dashboard imports Stripe at module load.

We split every route into its own lazily-loaded chunk and kept only the landing page eager. Now the heavy modules — the dashboard and Stripe (52KB), the blog content (70KB), the API docs (30KB) — load only when you actually visit those routes.

OUR RULE
Optimize what the data points at, not what is fun to optimize.

Our vitals were green, so we did not touch them. The crawl was empty, so that is where the work went. The bottleneck is wherever the measurement says it is, not wherever your instincts want it to be.

Then we built a tool so it stays fixed

A one-time cleanup rots. So we wrapped the whole audit into a single command, seoaudit, that we can run against production or a local build and wire into CI. It checks every URL in the sitemap for a unique title, a description, a canonical, structured data, a single H1, and image alt text — and it can run the page’s JavaScript first, so it audits the site the way a modern crawler actually sees it. It also folds in the Core Web Vitals and broken-link passes, and exits non-zero on a blocking issue.

seoaudit https://dictatorflow.com --render --vitals --links

# every page: unique title, description, canonical, JSON-LD
# duplicate titles: 0   pages with JSON-LD: 25/25

A lot of this ran through our own agent tooling — the same benchmarking, link-checking, and audit commands we keep in our dotfiles and point at every project. The agent measures, proposes, and then has to prove the fix against the tool. If the audit does not go green, the change is not done.

What changed, in numbers

SignalBeforeAfter
Unique page titles125
Pages with structured data025
Crawlable without JSNoYes (prerendered)
Sitemap URLs13 (stale)25 (auto-generated)
Stripe on landing page232KB0 (route-split)

The vitals were already good. We kept them good, made every page tell search engines and humans what it actually is, and left behind a command that fails the build before a regression can ship. That is the whole job: measure, fix the real thing, and make it stay fixed.