Technical QA of an Astro + Sanity site with Claude Code: 22 findings, a 4× faster build, and Bunny Stream locked down
We finished migrating our own site to Astro + Sanity. The build was green and the site looked right. We audited it anyway with Claude Code and found 22 problems no automated tool was flagging — plus a video CDN open to anyone.
The build was green. We had just finished migrating mentaexperts.com from Webflow to Astro + Sanity. 216 pages, bilingual, fully static. astro check clean, astro build clean, the site looked right.
We audited the code anyway, with Claude Code, across seven areas: bugs, accessibility, performance, SEO, security, duplication, and technical debt. Twenty-two problems surfaced that no automated tool was flagging. The build went from 6m36s to 1m38s, and along the way we discovered our Bunny Stream videos were wide open for anyone to burn billed bandwidth.
Here is what we found, how we found it, and why a green build means nothing.
Why audit something that already works
The site was done. Zero type errors, zero warnings, successful build, pages matching the design. By every automated measure, ready to ship.
We audited it anyway, end to end: bugs, accessibility, performance, SEO, security, duplication. Not a quick pass — reading every file and, as it turned out, the part that mattered most: measuring the actual output instead of trusting that the code says the right thing.
Twenty-two problems came up. The compiler caught none of them. Most had been sitting in the repository for weeks.
It is worth understanding why. TypeScript verifies that types line up. The build verifies that the code compiles. Neither one verifies that the result is correct. An href can point at a route that does not exist and compile perfectly. A template can emit seven <h1> elements and still be valid HTML. A file can request the same data a thousand times and finish without errors.
That distinction — valid code versus correct result — runs through everything below.
1. What a green build cannot see
The favicon that 404'd on all 215 pages
The base layout had this:
The file is called favicon.png. No _menta.
It is a string. Astro does not validate it, TypeScript does not look at it, the build never notices. The site went the entire migration with no favicon and nobody spotted it, because in development the browser shows a generic icon and you stop seeing it.
We found it with a twenty-line script: walk the generated HTML, pull every href and src starting with /, and check them against the files the build actually produced. One broken destination across the whole site. That one.
The same pass turned up webclip.png sitting there unreferenced: the apple-touch-icon was missing.
Up to seven <h1> elements in a single article
The 45 blog articles came over from Webflow's rich text. The Portable Text renderer defined h2, h3, blockquote, and normal. Nothing else.
So what happens to the h1 blocks the content carried? They fall through to the default renderer and come out as bare <h1> elements — no design system class — on top of the <h1> the template already renders for the title.
The worst case:
Twelve articles affected, across two languages. Counting the generated HTML: 18 unstyled `<h1>`, 17 `<h4>`, and 2 tables falling through to the default renderer and rendering with the browser's default typography.
The fix was not deleting them but demoting them to h2: they sit at the same level as the rest of the article's sections, they enter the table of contents, and the title goes back to being the page's only h1.
92 of 214 sitemap URLs with no hreflang
This is the costliest of the three, and the most instructive.
The site is bilingual with per-language slugs: /services/brand-design in English, /es/servicios/diseno-de-marca in Spanish. Not a prefix — a different slug. So the sitemap's hreflang has to be assembled by hand, pulling the real pairs from the CMS.
That map lived in astro.config.mjs and carried a comment of its own:
They drifted apart anyway. When the blog was added, it went into sanity.ts and not into the config. Result: the 45 English articles, the 45 Spanish ones, and both index pages came out of the sitemap with no language counterpart. 92 of 214 URLs declaring they have no alternate version.
The lesson is not "leave a comment." The comment was there. The lesson is that a comment is not a mechanism: if two places have to agree, either they share a source or they will drift.
2. Six and a half minutes of build, at 2% CPU
The build took 6 minutes 36 seconds for 216 pages. Almost two seconds per page, on a static site with no image processing at build time.
The number that closes the diagnosis is the other one:
2% CPU. It was not compiling. It was waiting.
The site shell — layout, navbar, footer, CTA section — was requesting the same three documents on every page:
Six to seven identical queries × 216 pages ≈ 1,400 round trips to the CMS returning exactly the same thing. And since the client ran with useCdn: false — correct, so freshly published content is never served stale — every one of them actually hit the network.
The fix is eleven lines:
It caches the promise, not the result: two pages rendering in parallel share the in-flight request instead of firing two. The cache lives in the module, so it lasts exactly as long as the process — every build starts clean, and useCdn: false keeps guaranteeing fresh content.
Applied to all 22 parameterless queries:
Four times faster. Per-page render time dropped from 1,850 ms to 4 ms. Not one line of markup changed.
3. The <div> that looked like a button
The site's FAQ accordions looked and behaved like accordions. You clicked, they opened.
The markup was this:
No role. No tabindex. No aria-expanded. No keyboard handling.
With a mouse it works. With a keyboard it does not exist: Tab skips it, Enter does nothing. To a screen reader it is a <div> with text inside — it never announces that it is interactive, or whether it is open or closed.
It was on eight pages, including the home page.
The interesting part is that the site already had the correct pattern implemented. The megamenu used role="button", tabindex="0", Enter and Space handling, Escape to close, and focus management. Someone got it right once and it was never replicated.
Since the accordion markup was duplicated across eight files and the behavior lived entirely in a single site.js, we applied the fix there:
One place, eight pages fixed, zero risk of missing a file. Plus a :focus-visible ring, because the browser default outline is invisible on a dark background.
4. CSS variables that did not exist
The language switcher had this:
It reads like code using the design system. It is not. Neither variable is defined in any CSS in the project. The fallback always wins: these are hardcoded colors in disguise.
And the disguise is the problem. A loose #3bbfad looks odd in review and someone asks about it. A var(--color--menta, #3bbfad) looks correct and nobody reads it twice. The day the brand green changes, that spot never hears about it.
We caught it by comparing sets: extract every variable *used* across the CSS and components, extract every one *defined*, subtract. Two orphans.
The same sweep showed the brand green written literally in five rules of our own CSS, while the token --_primitives---colors--menta holds exactly that value.
And something worse: 125 decorative star `<div>` elements copied by hand across eleven files, each with its own inline rgba() and pixel values:
Twelve unique stars, repeated ten times over. Changing the density meant editing eleven files. It is one component now, and size and color are resolved by CSS.
5. 8,422 lines for 13 pages
The structural finding. The site is bilingual and every page existed twice as a file: index.astro and es/index.astro, about-us.astro and es/sobre-nosotros.astro, and so on.
They were not two different files. They were the same file copied with the words swapped:
Thirteen pairs: 8,422 lines across 26 files, of which roughly 3,400 were pure duplication.
The cost is not disk space. It is that every visual fix has to be made twice, and if you forget one, the other language is wrong with nothing to warn you. It had already happened to us: the 92-hreflang bug was exactly that.
The solution is the obvious one — a shared component taking locale, two eight-line routes — but applying it to 26 files by hand is precisely the kind of task where silent errors creep in. So we wrote a tool that aligns both files line by line, extracts the changing text nodes into a dictionary, and emits a single markup.
And that is where it gets interesting.
6. How to verify a refactor that touches 8,000 lines
This is the section that helped us most.
The tool worked: astro check reported 0 errors, astro build generated all 216 pages. By every automated measure, the refactor was fine.
It was not.
The method that exposed it is simple: save `dist/` before the refactor, rebuild, and compare the output. Not the raw files — formatting shifts for irrelevant reasons — but what matters: the visible text with tags stripped, plus every href and src. Normalizing upfront the differences that are intentional.
If content and links come out identical, the refactor is correct. If not, there is something to look at. This is not a heuristic — it is the actual result.
It found four bugs that neither the types nor the build could see.
Bug 1: the Spanish page linking to English routes
The extractor compared text nodes. An href inside a JavaScript expression is not a text node, so it never saw it — and since that same line did contain changing text, nothing raised a flag either. It emitted the English version silently.
Bug 2: a component turned into a string
On the home page, the final CTA was written differently in each language: <CtaSection /> versus <CtaSection locale={locale} />. The tool treated it as localizable text and stored it in the dictionary:
Injected with set:html, the browser sees an unknown element and renders nothing. The CTA section vanished from the home page, with no compile error and nothing in the console.
Bug 3: a meta description showing the hero paragraph
Dictionary keys were generated from the text itself. Two different sentences on the contact page started the same way — "Schedule a consultation to…" — and produced the same key. The second overwrote the first.
The result: the <meta name="description"> on /contact and /es/contacto showed the hero paragraph, <br /> tags and all.
Bug 4: nine pages with the entire frontmatter untranslated
The worst of the four. The tool aligned frontmatter by comparing line counts; when they did not match, it discarded and kept the English.
The English files carried long explanatory comments the Spanish ones did not repeat. Nine of the ten pages did not match. The whole string dictionary stayed in English.
The fix was to align while ignoring comments and blank lines. But what matters is how it surfaced: not from the compiler, but from comparing the generated HTML.
Fail loudly
Each bug was fixed twice: the specific case, and the tool itself so that failure mode could never recur silently.
After those guards went in, the tool started aborting on cases it used to wave through. Every abort was a bug that never shipped.
Final result: 0 of 216 pages with any content or link difference. The only remaining differences are two, both intended: apostrophes now render as ' (coming from a variable, Astro escapes them; it renders identically) and the resolution of the grid videos.
Along the way, the comparison also surfaced two bugs that already existed before the refactor and that nobody had seen: the Spanish sitemap linked to /es/what-we-do and /es/industries — routes that do not exist — and the contact meta description was already wrong.
7. What we decided not to do
Two findings were deliberately left open. Both are security-related, and in both cases doing it badly is worse than not doing it.
The CSP shipped in `Report-Only`. A Content-Security-Policy is an allowlist of permitted origins: the strongest defense against code injection. Our site loads from the CMS, the video CDN, YouTube and Vimeo embeds, and the forms service. If we write that list from memory and miss one, that resource stops loading with no visible error: the image does not appear, the form does not submit, and you find out from a client.
In Report-Only the browser blocks nothing and only records what it would have blocked. Two weeks of real traffic, review the report, and only then switch it on with a list validated against what the site actually uses.
HSTS stayed commented out. It forces HTTPS on the domain for the entire max-age. Once a browser caches that header, there is no way to revert it from the server: you wait for it to expire. Enable it before the final domain is serving HTTPS reliably and any subdomain without a certificate is unreachable for a year.
Both went into the launch checklist with the reasoning written down. A documented pending item with a date is a different thing from a forgotten one.
8. The videos were open to anyone
The portfolio uses Bunny Stream for its background videos. The security audit started with the obvious — headers, secrets in the repo, escaping of CMS content — and ended somewhere we had not considered.
A bare curl against the MP4 URL:
No browser, no Referer, no token. 200 and 3.3 MB. And every resolution open, up to 1080p at 6.9 MB.
Bunny bills for delivered bandwidth. Every download is paid for, wherever it comes from.
A CSP does not protect this
Worth spelling out, because it is a common misconception: a Content-Security-Policy is an instruction the browser applies inside your pages. It controls what your site is allowed to load. It cannot stop someone from pasting the video URL somewhere else, or a script from pulling it in a loop. The bot never touches your site.
What does protect it is the Pull Zone's hotlink protection: an allowlist of hostnames checked against each request's Referer, plus blocking direct access for requests arriving with no Referer — exactly how a bot asks.
Two details that cost an afternoon
Bunny matches the hostname exactly, port included. We measured it:
Two things there. First: `localhost` does not work as a referrer — Bunny rejects it for not being a valid domain — so turning the protection on breaks video in development. Second: local.mentaexperts.com and local.mentaexperts.com:4300 are different entries; you have to add the one the browser actually sends.
The fix for development is to point a real hostname at 127.0.0.1 in /etc/hosts, add it to the allowlist, and tell Vite to accept it:
The spending cap is not where you would look
The billing auto-recharge is not a limit: it tops up your balance when it drops below a threshold, which is the opposite of what you need. The real ceiling is the Pull Zone's monthly bandwidth limit: when it is reached, Bunny disables the zone and stops charging.
We calculated it against the most expensive rate among our markets (South America, $0.045/GB) so the ceiling holds in the worst case, and paired it with per-IP limits — which stop an abuser without taking the site down for everyone.
And half the weight, incidentally
The grid previews render inside ~600 px cards and were being served at 720p. Dropping them to 480p is indistinguishable at that size and weighs half. The detail-page heroes stayed at 720p: those do render full width.
One number that showed up while measuring and is worth keeping in mind: the CDN sends cache-control: max-age=2592000 — 30 days — so local development downloads each video once a month, not once per reload. Walking the entire 14-video catalog is 98 MB, under a cent. No development-mode optimization was needed at all, and measuring it avoided building a pointless complication.
The numbers
Separately: the 7 remaining JPGs were converted to AVIF at the same resolution (2.49 MB → 0.47 MB, −81%), 48 images no file referenced were deleted, and GSAP and SplitType moved off three different CDNs — with GSAP running core 3.15.0 alongside its ScrollTrigger plugin at 3.14.2, a combination the library itself does not support — onto versions pinned in package.json.
What we took away
A green build says the code compiles, not that the result is correct. The three bugs in the first section — favicon, <h1>, hreflang — were all perfectly valid code producing wrong output. No type checker was ever going to find them.
Measure the output, do not read the code. Comparing the generated HTML found four bugs in minutes that reading the files by hand had not found in hours. If a refactor should not change the result, prove it: save the build, rebuild, diff.
A comment is not a mechanism. The duplicated route map had "this must mirror the other file" written on it. They drifted anyway. If two places have to agree, either they share a source or it is only a matter of time.
When something fails, fix the failure mode too. Every bug in the tool was fixed twice: the specific case, and the validation that stops it from happening silently again. That second part is what found the next three bugs.
Failing loudly beats working quietly. The version of the tool that emitted English without warning looked more robust: it never broke. That was exactly the problem.
What you do not measure, you get wrong in both directions. We assumed local development was costing us money on Bunny: it was cents. We assumed the site was reasonably fast: the build was wasting five minutes waiting on the same response fourteen hundred times.
*This article documents the audit of mentaexperts.com, a 216-page Astro + Sanity site migrated from Webflow, carried out with Claude Code in July 2026.*