TRAINYOURAGENT

TrainYourAgent.com: 708 URLs, 708 unique titles, zero duplicates, enforced at build

This site is 708 prerendered URLs generated from one route manifest, each with a unique title, a unique h1 and a canonical derived from a single origin constant. The build throws on a duplicate title, a duplicate h1, a title over 60 characters or a description outside 140–160. You can check all of it.

The problem

A site with a few hundred programmatic pages is trivially easy to build and almost impossible to keep honest. The failure is always the same shape: a template generates pages, the pages share a title pattern, and six months later Search Console reports several hundred duplicates, a dozen canonicals pointing at the wrong host, and a set of routes the router can render but the host cannot serve. This site had all three. The apex domain issued a 307 to the www host, and the repository contained roughly 120 hardcoded apex origins against 45 www ones — so Google read a www canonical in the prerendered wave and an apex canonical after the JavaScript rendered, and indexed both hosts as competing rows for the same page. Separately, `vercel.json` carried a catch-all rewrite, so a React route with no prerendered file returned the homepage with a 200 instead of a 404. Every one of those is a class of defect that is invisible in review and only shows up in a search console months later. The fix was not to be more careful. It was to make each of them impossible to commit.

One manifest, derived from the data rather than transcribed

`scripts/lib/route-manifest.mjs` is the single authoritative list of every statically renderable URL. Both the prerenderer and the sitemap read it and nothing else, so the two cannot disagree — the sitemap and the files on disk are identical by construction rather than by discipline. Critically, the manifest is derived rather than declared. It reads the real data modules through a generated TypeScript bridge: verticals and cities from the content modules, capabilities from `cornerstones.ts`, playbooks from `playbooks.ts`, blog posts from MDX frontmatter, docs from `docs.ts`, templates from `nicheSiteTemplates.ts`, competitors from the versus pages. Nothing transcribes a slug list. Adding a city, a vertical, a doc or a blog post makes its page and its sitemap entry appear on the next build with no edit to the manifest. The current expansion is 708 URLs from 102 hand-authored singleton routes: 200 location pages, 121 local pages, 98 blog posts, 95 alternatives, 25 templates, 15 playbooks, 15 build pages, 12 docs, 10 vertical hubs, 10 capabilities and 5 versus pages.

The build throws rather than warns

`scripts/prerender-routes.mjs` walks the manifest and writes one `dist/<route>/index.html` per URL. As it goes it asserts, and every one of these is a thrown error that fails the build rather than a warning:

The build throws rather than warns — specifics

Canonicals from one constant, and a routing guard that enumerates real slugs

`src/lib/site-origin.ts` exports `SITE_ORIGIN` and `canonicalFor()`, and there is a byte-identical `.mjs` twin for the build scripts, which cannot import TypeScript. Every canonical, `og:url`, JSON-LD `@id`, sitemap `<loc>` and absolute asset URL derives from it. `canonicalFor()` strips the query string and the fragment, because `?utm_source=` and `#section` are the two classic ways one page becomes a dozen indexed URLs. The origin is not written as a literal anywhere else in the repository. `scripts/verify-routing.mjs` closes the last gap. Since `vercel.json` no longer carries a catch-all rewrite, a React route with no home is a hard 404 for a real visitor rather than a soft fallback. The script proves the routing contract holds for every URL the SPA can actually render: a concrete URL is reachable only if a prerendered file exists for it, or an explicit rewrite matches it, or a redirect matches it. Anything else exits non-zero, and it runs inside `npm run build` after the prerender step. The detail that makes it trustworthy is how it generates the URLs it checks. An earlier version substituted a fixed probe token into every parameterised route, which both missed real gaps — one probe cannot represent 95 alternatives slugs — and invented nine false positives, because `/vs/x` is not a route anyone can reach while `/vs/airagent` is. The current version enumerates the real slug set for every dynamic family from the same data bridge the manifest uses. The URLs it checks are exactly the URLs that exist. The prerenderer also writes a real `dist/404.html`, so an unknown path returns a genuine 404 with a page listing real destinations, rather than the homepage with a 200.

The numbers, and the command behind each one

What is genuinely hard about this

Deriving a route manifest from source data rather than declaring it sounds obviously correct and is genuinely awkward to implement, because the build scripts are Node ESM and the content lives in TypeScript modules that import from `@/` aliases and occasionally from React. The bridge that makes `src/**` readable from a `.mjs` script without booting a bundler is the least visible and most load-bearing part of this system. The alternative — a hand-maintained slug list — is what every one of these sites starts with and is the reason they all eventually drift. Writing 708 unique meta descriptions that are each between 140 and 160 characters is not hard in the intellectual sense. It is hard in the sense that the constraint has to be enforced mechanically or it is not met, and once it is enforced mechanically, every template that generates descriptions has to compose real content into a narrow band without truncating mid-word or producing something that reads as generated. The 140–160 assertion has failed builds, which is the evidence that it is doing something. The genuinely instructive part was the canonical incident. Roughly 120 apex origins against 45 www ones is not a bug anyone introduced — it is what happens when a hundred files each independently write a URL. No amount of review catches it, because each individual instance is correct-looking. The only fix that holds is a single exported constant plus the rule that the origin is never written as a literal, and even that needed a duplicated `.mjs` twin because the build scripts cannot import the TypeScript one. That duplication is a wart, and it is documented as a wart in the file.

What this entry does not claim

Stack and status

Built with React 18 + TypeScript, Vite, React Router 6, Tailwind CSS, Radix UI, Node ESM build scripts and Vercel. Relationship: Our own company. Stage: In production. Period: 2026. Our role: Everything — architecture, content system, prerenderer, routing guard Figures re-derived on 2026-08-23.

Where a supplied figure was wrong

Verified on 2026-08-23 by executing buildManifest(): 708 routes, 708 unique titles, 708 unique h1s, 0 missing, 0 unrouted paths. The '123 real pages' figure did not reconcile with any measure in the repository — the accurate figure is 102 hand-authored singleton routes (1 home + 101 static), which is what is published here.