TRAINYOURAGENT

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

This site is 586 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. It was 1,024 until we measured the 440 city pages at 96% identical to one another and redirected them permanently into their vertical hubs. You can check all of it.

What it looks like

The trainyouragent.com work index, headed “Eleven things we shipped. Every number is checkable.”
One of the 586 prerendered URLs — the index this page is listed on. www.trainyouragent.com/work, captured 2026-09-28.

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 586 URLs from 103 hand-authored singleton routes: 121 local pages, 104 alternatives, 93 blog posts, 26 build pages, 25 templates, 22 vertical hubs, 16 comparisons, 15 playbooks, 13 tools, 12 docs, 12 work pages, 10 capabilities, 7 service pages and 5 versus pages. Deriving the manifest cuts both ways, and that is the useful part. Adding twelve vertical hubs in one commit added those twelve pages with no edit to the manifest. Deleting one call — the `locationRoutes()` generator — removed 440 pages the same way, along with their prerendered files and their sitemap entries, in a single line.

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.

Deleting 440 pages on purpose

The manifest used to emit a `/:vertical/:city` grid — 22 verticals × 20 cities = 440 URLs. Every one of them passed every assertion described above: unique title, unique h1, self-referencing canonical, description inside the 140–160 band. Measured against each other on 8 September 2026 they were also 96.4% identical. That gap is the honest limit of everything else on this page — uniqueness enforced at the byte level is not the same as a page being a distinct answer, and our own site was the best available proof of it. The first response was the cautious one: mark them noindex, drop them from the sitemap, leave them serving. That was the wrong call. A page that is still reachable, still internally linked and still in the index is still competing with the hub it should be feeding, and Google’s scaled-content guidance applies to the domain rather than to the pages that earned it. Removing 440 URLs from a sitemap does not remove 440 thin pages from a site. So they are gone. `locationRoutes()` is no longer called from `buildManifest()`, which drops the routes, the prerendered files and the sitemap entries together, and 22 entries in `vercel.json` send `/:vertical/:city` to `/:vertical` permanently — `permanent: true`, which Vercel serves as a 308 and Google treats the same way it treats a 301 — so every existing link and any accumulated equity lands on the hub. The generator itself is still in the file with a note recording the measurement that retired it, because the case for bringing it back — city pages with genuinely local content in them — is a real case; it just is not one we can make today. For three weeks this page published 1,024, which was the more marketable number. 584 is the figure where every URL behind it is a page we would put in front of someone.

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 584 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-24.

Where a supplied figure was wrong

Re-verified on 2026-09-16 by executing buildManifest(): 584 routes, 584 unique titles, 584 unique h1s, 0 missing, 0 unrouted paths. Down from 1,024 on 2026-09-09, deliberately: the 440 /:vertical/:city pages were retired and permanently redirected into their 22 vertical hubs after measuring at 96.4% identical to one another; verified in production on 2026-09-16, /hvac/austin answers 308 with location: /hvac. The most recent additions are /blog/smith-ai-alternatives-2026 and five hand-written /alternatives/ pages for the receptionist vendors an SMB owner actually searches. tests/offer-integrity-guard.mjs asserts every figure on this page against buildManifest().length, so the count on this page fails the build rather than going stale — which is how the previously published 1,024 got caught within one commit of becoming untrue. The '123 real pages' figure in an earlier version did not reconcile with any measure in the repository; the accurate figure is 103 hand-authored singleton routes (1 home + 100 static + /pricing + /partners/currentads), which is what is published here.