The build will not compile if a tax number is missing its paperwork.
That is not a figure of speech. On a side project of mine — a US take-home-pay calculator — every rate, bracket, and standard deduction lives in a JSON file, and each one has to carry provenance: the tax year it applies to, the date a human last verified it, a source.url pointing at an official .gov, and the timestamp of when it was fetched. Importing the data module runs a validation gate that checks all of this and throws on anything malformed. A single bracket whose rate decreases where it should climb, a standard deduction that has quietly drifted out of sync with the federal one it's legally supposed to mirror — the build stops, with a message telling you exactly which number is wrong. I once proved this to myself by nudging one state's deduction by a dollar. The build died on cue.
I built the thing this way on purpose. The niche is what Google calls YMYL — Your Money or Your Life, the high-stakes category where wrong information can wreck someone's finances or health. Taxes sit squarely inside it, and Google scrutinizes that content harder than almost anything else, for a reason that is not entirely about virtue: finance is one of the most valuable ad segments there is. General-interest pages might earn a couple of dollars per thousand impressions; tax and finance pages can run an order of magnitude higher. High reward, high scrutiny. If you want to make money there, you have to survive a quality review built specifically to keep low-effort operators out.
So I built the opposite of a low-effort operator. This is, deliberately, the anti-content-farm — and I want to lay out exactly what that discipline looked like, because the whole point of this essay is that it wasn't enough.
The ethos
Six rules held the project together, but they reduce to a few ideas.
Data is separated from logic. The tax engine is a pure function — money in, a full breakdown out, no side effects — written once and then left alone. It doesn't know about California or Texas specifically; it reads a data layer. When a state changes its rates, I change data, never code. That separation is what makes the second validation gate trivial: because the engine is pure, the same function that renders a page during the build is the one that runs in your browser, and I can pin its behavior with unit tests that never lie about what production does.
Every claim is traceable. The site's headline feature is that it shows its work: expand any line of the breakdown and you get the exact arithmetic for your numbers, ending in a dated link to the official source. The bracket math that appears on screen is generated by a function that provably sums to the same total the engine computed — enforced by a test, not by my good intentions.
The human publishes; the agent only prepares. There are three gates before anything goes live: machine checks (the provenance-and-plausibility validation that runs on import), then unit tests, then a human sign-off against a primary source. Pushing straight to the production branch is blocked, because merging to main auto-deploys. A tool can do the legwork of cross-checking a number against a .gov page. It cannot tick the box that says a human verified it. That line — the agent prepares, the human publishes — is the spine of the whole thing. It is, precisely, what a content farm does not have.
If you were designing a site to be maximally trustworthy, number by number, this is roughly what you'd draw up. And number by number, it worked. What I had not internalized is that Google does not grade you number by number.
The temptation of scale
Then I built a comparison page. Pick two states, see them side by side — a genuinely useful thing, and the natural next feature once shareable scenario URLs were in place.
The first version was modest and careful: a curated handful of pairs, restricted to states I had already fully verified. But it's a short walk from "compare two states" to "why not compare all of them?" Fifty states, every pairwise combination. That's C(50,2) — fifty choose two — which is 50 × 49 / 2 = 1,225 unique pairs. I built positional routes for the whole set, /compare/[stateA]/[stateB]/, and every one of those pages went into the sitemap. Counting the single-state and hub pages, the build ballooned to around 2,500 comparison pages where there had been a handful.
It felt like progress. More coverage, more URLs, more surface area for someone searching "California vs Texas take-home pay" to land on. Each page was real — driven by the same audited engine, the same sourced data. Nothing about it was fake.
That was the trap, and I walked straight into it. I was thinking about correctness, which was airtight, and not at all about shape — what 1,225 near-identical programmatic pages, appearing all at once, look like from the outside.
The rejection
The AdSense verdict came back: disapproved. The reason given was the boilerplate one — "insufficient content" / low content quality.
My first reaction was that this had to be a mistake. Insufficient content? The site had roughly fifty state pages, each with two to four unique paragraphs of hand-written prose plus its own FAQ. Full legal pages. A methodology page. A rankings page with a real map. This was, if anything, over-built. Whatever "thin content" is supposed to mean, this wasn't it — not in the usual sense of a page that's all ads and no substance.
Which is exactly why the rejection was so useful, once I stopped being defensive about it. The problem was not that any individual page was thin. The problem was a signal operating one level up, at the scale of the whole site — and the boilerplate rejection letter gives you no way to see it. You have to go find it.
The investigation
I went to Google Search Console and looked at the Pages report. The count of discovered URLs read about 1,298.
That number made no sense against a site that, at rest, is a few dozen pages. So I lined up the timeline, and it told the whole story.
The positional compare routes for all fifty states landed on June 23rd. The sitemap advertising them went to Search Console the same day, and Google read it on the 26th — which is where the 1,298 came from: my few dozen real pages plus the ~1,225 programmatic compare pairs. On the 26th I also shipped the fix that moved comparison into query parameters and tore the positional routes back out. The AdSense disapproval landed on the 27th.
So for roughly three days, about twelve hundred near-duplicate, machine-generated pages were live and in the sitemap — and those three days fell squarely inside the AdSense review window. To a reviewer, human or automated, that is the exact fingerprint of scaled content: a sudden bloom of templated pages that differ only by which two nouns got slotted into the same layout. Doorway pages. The thing the quality guidelines exist to catch. I had, without meaning to, done a passable impression of the thing I'd spent weeks engineering against.
The fix
The repair had three moves.
First, collapse the surface. I moved state selection out of the URL path and into query parameters — /compare/?stateA=CA&stateB=TX. One page does the work that ~2,500 build-time pages had done. The comparison feature didn't lose a thing a user cares about; it lost only the property of minting a distinct static URL for every pair. Compare pages in the build went from around 2,500 back down to 1.
Second, thicken the content that remained, honestly — four standalone tool pages, each a real calculator with its own intro and FAQs, not filler. More genuine reasons to be on the site, fewer templated ones.
Third — and this is the part with the sharp edge — deal with the ~2,500 stale URLs Google had already indexed. On a normal Next.js app you'd reach for the framework's redirects(). But this site is a static export (output: "export"), and static export explicitly forbids redirects and rewrites — there's no server to run them on. The escape hatch is that redirects in vercel.json are a platform-level mechanism, handled at Vercel's edge, entirely distinct from the framework feature that static export bans. Using them didn't compromise the zero-server model at all. So I shipped a 308 permanent redirect from the dead compare pairs to the hub.
And it 404'd. Which taught me the most specific lesson of the whole affair.
One more subtlety: the redirect points every stale pair at the hub, not at some resurrected comparison. That's deliberate. The old URLs were slug-based (california, texas); the query comparator wants USPS codes (CA, TX). You cannot map a slug to a code inside a single wildcard pattern, so there was no clean way to send /compare/california/texas/ to its query-string equivalent — and, more to the point, I didn't want to. The goal wasn't to preserve those pages. It was to make them disappear from Google's index. Sending them all to the hub does exactly that.
Then the retroactive proof. I resubmitted the sitemap and watched the Pages report. Discovered URLs fell from 1,298 to 63. That drop is the whole diagnosis, confirmed after the fact: subtract the 63 real pages from the 1,298, and the ~1,225 that evaporated are precisely the compare pairs. The bloat had a name, and now it had a number.
The lesson
Here is the thing I keep turning over. I have two bots reviewing every pull request, and across the project they caught dozens of real tax-data errors before any of them shipped — a stale top rate a legislature had cut retroactively, a filing status quietly copying the wrong bracket schedule, a deduction pulled from the wrong year. Every one got verified against a primary source by a human and fixed. That is what "with the help of AI" looked like on this project: assistance wired directly into the same human-in-the-loop rigor, never a substitute for it. The discipline was real, and it was aimed with real precision at the trustworthiness of every individual number.
And it did nothing to protect me here. Not because it failed — because it was pointed at the wrong axis entirely. Every gate I built operates at the level of a claim: is this rate right, does it trace to a source, has a human confirmed it. Not one of them operates at the level of the site: what does the aggregate shape of these URLs signal to a quality system that has never read a single one of my sourced brackets and never will?
That's the tension I underestimated. Scale and trust pull against each other in YMYL, and they pull hardest exactly where you feel most justified — because you know your pages are good, so generating a thousand more of them feels like generosity, not spam. But "is this page good?" and "does this site look like a farm?" are different questions, judged by different machinery, and the second one is answered by signals you don't fully control and can't fully see. Comprehensive coverage and doorway-page spam are, from the outside, the same silhouette. The line between them is drawn by Google, at the site level, and your intentions are not admitted as evidence.
The most expensive lesson of the project cost me no data errors and no engineering mistakes in the ordinary sense. Every number was still correct. I simply forgot that in this niche, being right about the numbers and being trusted about the site are two separate achievements — and I had built an elaborate machine for the first while leaving the second to luck.
The site is a static Next.js export; the numbers and the AdSense timeline referenced here come from the project's own history. It is not tax advice — just a build story with a bill attached.