DEV Community

Cover image for From 3 clicks to 664: what a real SEO audit found in my React SSG site
Roman Popovych
Roman Popovych

Posted on

From 3 clicks to 664: what a real SEO audit found in my React SSG site

Three months ago I wrote a post about adding JSON-LD to my side project. The result was 1,600 impressions and 3 clicks over 28 days. Average position: 61.3 — page six of Google.

Today's Search Console, same 28-day window:

Metric Then Now
Clicks 3 664 (+149%)
Impressions ~1,600 21,400 (+30%)
Average position 61.3 23

The site is devtools.abect.com — 49 browser-based developer tools (image converters, text/code converters, SEO generators). React 19, Vite, statically prerendered at build time, no backend for the tools themselves.

This post is two things: what actually moved the needle over three months, and what a proper technical audit found afterwards. The second part is where it gets interesting, because I found bugs that had been live for months without me noticing.


Part 1: the boring stuff that worked

No growth hacks here. Five things, all tedious:

One page per tool, not one page per category. /png-to-jpg, /jsx-to-html, /yaml-to-json — each a real URL with its own content, not /converter?from=png&to=jpg. This is the single highest-leverage decision I made. Search intent for "jsx to html" is specific; a generic converter page cannot rank for 22 different conversions at once.

Prerendering to static HTML. Every route is rendered to a complete HTML file at build time. Googlebot receives a fully populated document — headings, body copy, structured data — without executing a single line of JavaScript. React hydrates on top afterwards. No server rendering at request time, no database in the critical path.

5,000–12,000 characters of real content per page. Format comparison tables, framework-specific implementation guides (React, Next.js, Vue, WordPress), 10–12 FAQ items. Not padding — the kind of thing you'd actually read if you landed there confused.

JSON-LD on every page: WebApplication + HowTo + FAQPage, with the FAQ schema built from the exact same array that renders the visible FAQ, so they can never drift apart.

Titles and descriptions written against a specific pain, not the format pair. Compare:

❌ Convert WebP to JPG online free
✅ WebP won't open in Photoshop, Lightroom, Outlook or print shops.
   Convert to JPG instantly — universal compatibility, in your browser.
Enter fullscreen mode Exit fullscreen mode

Same tool. The second one matches what somebody actually types into Google when they're stuck.


Part 2: I aimed at the wrong niche

I built this project for image conversion. That was the whole premise — I was tired of ad-riddled converters that renamed my client's files. Image tools were 27 of the 49 pages.

Today's top pages:

Page Clicks Change
JSX to HTML 491 +117%
TSX to HTML 137 +813%
HTML to TSX 9 new
JPG to WebP 4 +100%

The text converters — which I added almost as an afterthought, in a single batch, because they were easy — carry the entire site. The image tools that motivated the whole project bring in a rounding error.

Why? Image conversion is a saturated commodity: hundreds of sites, big domains, ad budgets. "jsx to html" is a narrow developer query that a handful of pages compete for, and most of them are low-effort. I stumbled into a niche where a well-built page could actually win.

The lesson isn't "build text converters." It's that you find out which of your bets worked by shipping all of them and reading the data, not by reasoning about it beforehand. I'd have bet money on the wrong one.


Part 3: five bugs a real audit found

Three months in, I stopped adding features and audited the build output instead — not the source code, the actual HTML that ships. That distinction matters, and here's what it surfaced.

1. All my JSON-LD was rendering in <body>

React 19 automatically hoists <title>, <meta> and <link> into <head> no matter where you render them. It does not hoist inline <script> tags.

So this, which looks perfectly reasonable:

<Helmet>
  <title>{PAGE_TITLE}</title>
  <meta name="description" content={PAGE_DESC} />
  <script type="application/ld+json">{JSON.stringify(jsonLdApp)}</script>
</Helmet>
Enter fullscreen mode Exit fullscreen mode

…produced a <head> with the meta tags correctly hoisted, and the JSON-LD sitting in the middle of <main>, hundreds of lines down the document.

Google parses JSON-LD in the body just fine, so rich results were never broken. But some third-party validators only look in <head>, and — more embarrassingly — my own changelog claimed I'd fixed this months earlier.

The fix: stop rendering the script at all. Declare the schema, collect it during SSR, serialise it into <head> in the prerender pass.

// components/JsonLd/JsonLd.jsx
import { useContext } from 'react'
import { JsonLdSink } from './JsonLdSink'

export default function JsonLd({ data }) {
  const sink = useContext(JsonLdSink)
  if (sink && data) sink.push(data)
  return null   // renders nothing, on server and client alike
}
Enter fullscreen mode Exit fullscreen mode
// entry-server.jsx
const serializeJsonLd = (data) =>
  JSON.stringify(data).replace(/</g, '\\u003c')

export function render(url) {
  const jsonLd = []

  const markup = renderToString(
    <JsonLdSink.Provider value={jsonLd}>
      <StaticRouter location={url}><App /></StaticRouter>
    </JsonLdSink.Provider>
  )

  const jsonLdTags = jsonLd
    .map(d => `<script type="application/ld+json">${serializeJsonLd(d)}</script>`)
    .join('')

  return { appHtml: /* … */, headTags: hoistedHead + jsonLdTags }
}
Enter fullscreen mode Exit fullscreen mode

Because the component renders null on both sides, hydration always matches and no JSON-LD bytes are re-serialised into the client DOM.

That replace(/</g, '\\u003c') closes a real hole I'd been ignoring. React escapes the children of a JSX <script>, but the moment you build the tag as a string yourself, a FAQ answer containing the literal text </script> would terminate the tag early and inject arbitrary markup. < is valid inside a JSON string, so parsers still read the original character.

Usage now:

<Helmet>{/* title, description, canonical, OG */}</Helmet>

<JsonLd data={jsonLdApp} />
<JsonLd data={jsonLdHowTo} />
<JsonLd data={jsonLdFaq} />
Enter fullscreen mode Exit fullscreen mode

Same pattern let me move BreadcrumbList and Organization into Layout, so all 50 tool pages get them from one place instead of 50 copies.

2. A dollar sign corrupted an entire page

This is my favourite, because it's invisible until it isn't.

The prerender script injected rendered markup into the HTML template like this:

template
  .replace('<!--app-head-->', headTags)
  .replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`)
Enter fullscreen mode Exit fullscreen mode

Looks fine. It is fine — until the page content contains a dollar sign.

In String.prototype.replace, a string replacement treats $$, $&, $` and $' as substitution patterns. $& means "insert the matched substring here."

My LocalBusiness schema page explains price range indicators. The copy reads:

the "$", "$$", or "$$$" symbol shown next to the business name
Enter fullscreen mode Exit fullscreen mode

After HTML encoding, that becomes:

the &quot;$&quot;, &quot;$$&quot;, or &quot;$$$&quot; symbol
Enter fullscreen mode Exit fullscreen mode

Spot it? There's now a literal $& in the string — from $ followed by &quot;. So replace dutifully substituted the matched text, which was <div id="root"></div>.

The shipped HTML:

the "<div id="root"></div>quot;, "$", or "$<div id="root"></div>quot; symbol
Enter fullscreen mode Exit fullscreen mode

Three elements with id="root" in one document. Invalid HTML, mangled visible copy on an indexed page, and a hydration mismatch waiting to happen.

The fix is one character per call — a replacer function, which disables pattern interpretation entirely:

template
  .replace('<!--app-head-->', () => headTags)
  .replace('<div id="root"></div>', () => `<div id="root">${appHtml}</div>`)
Enter fullscreen mode Exit fullscreen mode

This bug shipped the day I wrote the prerender script and survived every review, because it only triggers on content containing $&, $$, $` or $'. Price ranges, regex examples, shell snippets — all landmines.

3. React.lazy would have destroyed my SEO

My main bundle was 1.4 MB, loaded on every page. The obvious fix is route-level code splitting. So I checked whether it was safe first — and I'm glad I did.

import { createElement as h, lazy, Suspense } from 'react'
import { renderToString } from 'react-dom/server'

const Real = () => h('main', null, h('h1', null, 'REAL CONTENT indexed by Google'))
const Lazy = lazy(() => Promise.resolve({ default: Real }))

console.log(renderToString(
  h(Suspense, { fallback: h('div', null, 'LOADING FALLBACK') }, h(Lazy))
))
Enter fullscreen mode Exit fullscreen mode

Output on React 19.2.4:

<template data-msg="Switched to client rendering because the server rendering
aborted due to: The server used &quot;renderToString&quot; which does not
support Suspense..."></template><div>LOADING FALLBACK</div>
Enter fullscreen mode Exit fullscreen mode

renderToString does not support Suspense. A lazy route emits its fallback into the static HTML. Naive route splitting would have replaced the indexable content of all 50+ pages with a loading placeholder — the exact opposite of what SEO needs, shipped silently.

So I split by dependency instead of by route. Heavy libraries move to dynamic imports at their call sites:

export async function buildZip(files, ext) {
  // 95 kB, only needed when the user clicks "Download all"
  const { default: JSZip } = await import('jszip')
  const zip = new JSZip()
  // …
}
Enter fullscreen mode Exit fullscreen mode

Same for marked, turndown and js-yaml in the text converters (cached after first use so repeat conversions don't re-import), and the framework itself split into its own chunk so it stays cached across deploys:

manualChunks(id) {
  if (!id.includes('node_modules')) return
  if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) return 'react-vendor'
  if (id.includes('react-router')) return 'router-vendor'
}
Enter fullscreen mode Exit fullscreen mode

Result: 1,462 KB → 1,160 KB eager (415 KB → 332 KB gzipped), with zero SEO risk. React.lazy is still fine for UI that never renders during prerender — the AI chat's Markdown renderer qualifies, because the prerendered page has zero messages.

4. Sign-in pages returned HTTP 404

My vercel.json had a catch-all sending anything not prerendered to 404.html with a 404 status. The auth pages weren't in the prerender list, because they have no SEO value.

Consequences I hadn't thought through:

  • The "reset your password" email linked to a page returning 404. Some scanners and preview bots treat that as a dead link.
  • Search Console reported 404s on URLs that were internally linked from the header of every page.
  • Hydration mismatch: the server sent NotFound markup, the client rendered Login. React 19 logs a recoverable error and re-renders the whole tree — a visible flash.

Fix: split the route list in two.

// Indexable: prerendered AND in sitemap.xml
export const sitemapRoutes = [
  { route: '/', lastmod: '2026-08-05', priority: '1.0', changefreq: 'weekly' },
  ...TOOLS.map(/* … */),
]

// Prerendered but deliberately absent from the sitemap.
// They must answer 200, not fall through to 404.html.
// Every page here MUST render <meta name="robots" content="noindex">.
export const noindexRoutes = [
  '/login', '/register', '/forgot-password', '/reset-password',
  '/profile', '/profile/usage', '/profile/billing',
]
Enter fullscreen mode Exit fullscreen mode

One subtlety cost me another twenty minutes: /profile/* still shipped without noindex. During prerender the auth store is in its loading state, so ProtectedRoute returns null and the child page's <Helmet> never renders. The directive had to move into the guard itself:

export default function ProtectedRoute() {
  const robots = (
    <Helmet>
      <title>Account — Abect Dev Tools</title>
      <meta name="robots" content="noindex, nofollow" />
    </Helmet>
  )

  if (loading) return robots
  if (!user)   return <>{robots}<Navigate to="/login" replace /></>
  return <>{robots}<Outlet /></>
}
Enter fullscreen mode Exit fullscreen mode

Related: my robots.txt now has no Disallow rules at all. That's deliberate. A crawler has to be able to fetch a page to read its noindex. Blocking /login in robots.txt while also marking it noindex means the URL stays indexable and the directive is never seen — the classic own-goal.

5. Security headers were never applied

curl -I on production returned exactly one security header, and not one of mine:

Strict-Transport-Security: max-age=63072000
Enter fullscreen mode Exit fullscreen mode

My config specified max-age=31536000; includeSubDomains; preload. Different value, missing directives — that was Vercel's automatic HSTS, not my configuration.

The cause: vercel.json mixed the legacy routes property with modern headers and cleanUrls. When routes is present, Vercel silently ignores the modern properties. No error, no build warning. My entire headers block had been dead config for months.

Removing routes and using rewrites instead brought all six headers live on all 55 pages.

Bonus: the same audit found that llms.txt — the file that tells AI crawlers what the site offers — listed 24 of 50 tools, with several shipped tools sitting under a "Coming Soon" heading. It's now generated from the tool registry at build time, and a new category without a matching section fails the build loudly instead of silently dropping pages.


Part 4: audit the output, not the source

Every one of these bugs is invisible in the source code. The JSX looks correct. The config looks correct. They only appear in the artifact that actually ships.

So I wrote a script that reads dist/ and asserts against the real HTML:

  • exactly one id="root" per document, no unreplaced template placeholders
  • one <title>, one <meta name="robots">, canonical matching the actual URL
  • every JSON-LD block parses as JSON, sits in <head>, and has @context + @type
  • FAQPage item count equals the rendered .FAQ__question count
  • no Suspense abort markers or lazy fallbacks anywhere
  • every internal href resolves to a prerendered route or a real file on disk
  • sitemap contains no noindex page and no private route
  • every tool in the registry is routed, prerendered, and linked from somewhere

First run: 14 failures. Two were real (the $& corruption, and an /about page rendering 10 FAQ items with no FAQPage schema). The other twelve were bugs in my audit script — including one caused by the real corruption, since my naive split('<div id="root">') broke on the page with three of them.

That ratio is worth internalising: most of what an audit flags is noise, and you have to verify every single one. But the two that survived had been live for months.

After deploying, I ran the same checks against the live site — all 55 sitemap URLs, plus the routes that aren't in it:

✓ 55/55 pages HTTP 200
✓ 55 unique titles, 55 unique descriptions, 55 unique canonicals
✓ 261 JSON-LD blocks, all valid and in <head>
✓ /login, /register, /profile → 200 + noindex (were 404)
✓ /ai/<id> → 200 + X-Robots-Tag: noindex, nofollow
✓ /nonexistent → real 404
✓ 6 security headers on every page
Enter fullscreen mode Exit fullscreen mode

Takeaways

Prerendering is the highest-leverage SEO decision for a tool site. Complete HTML on first crawl, no JS execution required, no server at request time.

One URL per intent. A single configurable page cannot rank for 22 different queries. Twenty-two pages can.

Audit the built output, not the source. Everything here was invisible in the JSX and the config.

Verify framework assumptions with an experiment. Ten lines of code proved React.lazy would have wrecked 50 pages. I'd have shipped it otherwise.

Never use a string replacement to inject rendered markup. $& will find you eventually.

Growth is slow and boring. Three months to go from 3 clicks to 664. No hack — just a lot of unglamorous work compounding.

I'll rerun the numbers in 28 days and report back on whether this batch moved anything. The site is devtools.abect.com, free and no signup.

Has your traffic ever come from somewhere you never targeted?

Top comments (0)