Sponsored Content

DEV Community

Cover image for My personal site was invisible to Google for four months
Joanne Skiles
Joanne Skiles

Posted on Originally published at chaotictoejam.com

My personal site was invisible to Google for four months

I am not an SEO expert. Louder for those in the back. I AM NOT AN SEO EXPERT. I've never been into marketing, social media is a necessary evil for me. But if I want to provide free content... well, I need people to be able to find the free content. So begrudgingly, I monitor things (albeit not often enough) one of those is Google Search Console.

So a few weeks ago I checked Google Search Console to see how things were "performing". Now, I wasn't really expecting much when I opened Google Search Console for chaotictoejam.com. But I wasn't expecting to see only one indexed page and five total clicks. Ever.

Now I could have just taken an easy explanation. It's a new site. There aren't many backlinks. Google is slow.

But the domain is not new. It ran as a self-hosted WordPress blog for years, first as a cooking/baking blog from a brief period when I was fairly sure I was going to be the "Next Food Network Star" (hey it could happen), then as a crafting blog whose single biggest hit was a set of wedding shower bingo cards. Eventually I got tired of patching WordPress and tore it down, and it sat as a plain HTML landing page doing nothing in particular. Four months ago I finally rebuilt it as the React site it is now. So the domain has history. What is new is the current site on top of it.

And that's exactly where the problem was.

The site was serving Google BLANK pages

Yes. Blank. Six identical blank pages, and it wasn't "fun" like the musical Six.

Here is what view-source on my own homepage looked like:

<title>chaotictoejam</title>
<meta name="description" content="...">
<div id="root"></div>
Enter fullscreen mode Exit fullscreen mode

That was it. And /talks, /tutorials, /podcast, /writing, /community all shipped the same thing. That meant they all had:

  • Same empty root div
  • Same title
  • Same description

My prerender step was only generating real HTML for /chaotic-commits/* and /resources/*. Everything else was a client-rendered shell.

That meant for the first pass of Googlebot, my six most important pages looked like six identical empty pages.

Now, Google does eventually run the JavaScript and see the real content. That is a slower, lower-priority second wave. But until then, thin/duplicate initial HTML is a ranking and indexing issue.

So I needed to prerender the static routes

This pattern already existed in the codebase for my episodes and resources page. I just had not applied it to the top-level pages because I am a solo dev and honestly didn't think about it. So I made three changes:

  1. A getStaticPageMeta(pathname) function that returns a real title, description, and JSON-LD for each of the six routes.
  2. Per-route structured data: WebSite on the homepage (layered on the existing sitewide Person), CollectionPage on the list pages, the PodcastSeries block on /podcast that had previously only been injected client-side.
  3. The prerender script loops those six routes through the same write step.

Here is the shape of it. A lookup table of the copy for each route, and one function that turns a path into everything the prerenderer needs to bake into <head>:

// src/lib/seo.js
const STATIC_PAGE_META = {
  '/':          { title: 'chaotictoejam | Joanne Skiles', description: '...' },
  '/talks':     { title: 'Talks | chaotictoejam',         description: '...' },
  '/tutorials': { title: 'Tutorials | chaotictoejam',     description: '...' },
  // ...one entry per top-level route
}

export function getStaticPageMeta(pathname) {
  const path = normalizeTrailingSlash(pathname)
  const meta = STATIC_PAGE_META[path]
  if (!meta) return null // not a route this function owns

  return {
    title: meta.title,
    description: meta.description,
    canonicalUrl: path === '/' ? `${SITE_URL}/` : `${SITE_URL}${path}`,
    jsonLd: STATIC_PAGE_JSON_LD[path](), // WebSite / CollectionPage / PodcastSeries
  }
}
Enter fullscreen mode Exit fullscreen mode

The prerender script already had this loop for episodes and resources. The static routes just get added to it:

for (const route of ['/', '/talks', '/tutorials', '/podcast', '/writing', '/community']) {
  await writeRoute(template, route, getStaticPageMeta)
}
Enter fullscreen mode Exit fullscreen mode

writeRoute renders the React tree for that path to a string, swaps in the real <title>, description, canonical, and JSON-LD, and writes dist<route>/index.html. Route / overwrites the empty index.html shell itself.

The homepage went from an empty shell to 7.8 KB of rendered HTML with a correct title. Not exactly glamorous. It should have been there from day one. But like I said, not an expert in marketing/SEO here.

I found bugs...

Prerendering more pages helped me discover some bugs. Those bugs had been invisible while the pages themselves were effectively blank, so... good thing I started down this rabbit hole.

Bug #1: A route that was linked everywhere but prerendered nowhere. I had a hand-built page at /resources/magic-8-ball. I built it as a funny joke for a friend (that missed the mark), but it was also a real React route. It was linked from the resources grid and from a podcast episode, so Googlebot crawled it. But it was not one of the markdown-backed resource files. So, the prerender script never emitted an HTML file for it. Google was hitting a URL that resolved to a soft 404.

Lesson: every entry in the router needs a matching entry in the prerender list, and those two lists have to be reconciled, not assumed to match.

Bug #2: Duplicate structured data. Google Search Console also flagged "2 invalid items" on a resource page. It turned out to be the same VideoObject counted twice. The prerender step baked a <script type="application/ld+json"> into the head, and then the React component appended an identical one when it mounted. Googlebot renders JavaScript, so it saw both (and both were flagged invalid because they were missing required fields). The fix was to give the baked block the same DOM id the component uses, so the component reuses it instead of adding a second copy. I also stopped emitting VideoObject for YouTube playlist URLs, which are not videos and never validated.

But not everything is about plumbing

Like I said before I am not a marketer or an SEO expert or any of that. Maybe this is obvious to other people, but it wasn't obvious to me. I honestly thought "If I built it they will come." Poor, naive, Joanne. Two of the biggest changes had nothing to do with rendering.

Stylized titles do not match how people search. My podcast episode titles are things like feat: i built a transparent magic 8-ball and hotfix: everything I know about distributed systems, I learned from Costco. I like them. Unfortunately nobody types that into Google. So I added an optional searchTitle field: it drives the <title> tag and the social card, while the visible <h1> keeps the stylized version. My weird voice on the page, plain language in the search result.

Your keyword-rich sentence should be visible, not just in the head. Every episode had a summary that went into the meta description and the JSON-LD and nowhere a human could see it. I moved it to a visible lede paragraph directly under the <h1>. Now the first line of real text on the page is also the line that describes what the page is about. Helps readers, helps Google, cost me about three lines of code.

Original text on your own domain matters a lot more than I realized My podcast pages rank because the audio lives on Spotify but the show notes and transcripts are hosted on my domain. My DEV.to, Substack, and Medium posts/articles are the opposite: the full text lives on their domains, Google already indexed it there, and a four-month-old personal site does not out-rank DEV.to. This is why my /writing section has now become commentary, not reposts. Each entry is my notes on a piece, the page is self-canonical, and the original is recorded as isBasedOn in the structured data rather than as the canonical URL. No duplicate-content problem, and the page can earn its own long-tail traffic.

New articles and posts will originate on chaotictoejam.com. DEV.to and Medium will point back here with canonical URLs. Substack, meanwhile, may get excerpts or accompanying notes because it doesn't support canonical URLs. (Rude.)

The AEO side is mostly the same work

I did not run a separate "answer engine optimization" project. The things that help an LLM or an AI search feature answer questions about me are:

  • An llms.txt file with a plain identity block, a map of every page, and an explicit statement that jlskiles.com is the same person.
  • A reciprocal Person JSON-LD sameAs on both my domains, pointing at each other, so an AI summarizer resolves them as one entity instead of two.
  • The same prerendered, structured, on-domain text that helps Google. An answer engine cannot cite a blank <div id="root"> either.

I added an llms.txt as a machine-readable map of the site. I don't consider it a magic ranking mechanism, but it costs very little to maintain and fits the broader goal of making the site's identity and content easier for machines to understand.

Structured data does double duty. You are not doing SEO and then also doing AEO. You are making the page legible to machines, and therefore both kinds of machine benefit.

The thing is, time is still a factor

I want to be honest here. There are things that "moved the needle" quickly and there are things that need to marinate (See? Still got the cooking side of me.) So it's worth being honest about the split:

  • In my control, fixed in an afternoon: prerendering, per-page titles and descriptions, non-duplicate valid structured data, a sitemap that is actually submitted, checking that Search Console is pointed at a Domain property so it covers www and non-www.
  • Not in my control, takes months: domain authority. Whatever authority the old blogs built up is mostly gone, and what survives is not the kind I want. The permalink structure changed twice, old inbound links point at URLs that no longer exist, and I never set up redirects. I could go hunting for the old paths in the Wayback Machine and my server logs, but they are years old, I do not remember them, and the effort is something I don't want to do. So in practice this is close to a fresh start, and a site with few relevant inbound links barely registers impressions for a long time regardless of how clean the HTML is. The plumbing is a prerequisite, not a growth hack.

One specific gotcha on that last point. My Search Console showed "1 indexed, 0 not indexed." The zero is the tell. It did not mean my other pages were queued. It meant Google had never discovered them at all.

The checklist version

If you run a React or Vite SPA and your site is not showing up:

  1. view-source on three different routes. If they are byte-identical empty shells, there's your problem, fix it before anything else.
  2. Prerender or server-render every route that has its own URL. Give each one a real, distinct <title> and description.
  3. Reconcile your router's route list against your prerender's route list. Anything linked but not prerendered is a soft 404.
  4. Pick one place to emit each piece of structured data. If you bake JSON-LD at build time, make sure the client doesn't append a second copy.
  5. Put your one keyword-rich sentence in visible body text, not only in <head>.
  6. If your titles are stylized (and I love that), add a plain-language title for the search result and keep the stylish one as the <h1>.
  7. Only host original text you are willing to be the canonical home for. Commentary on someone else's post is original text. A copy of it is not.
  8. Add an llms.txt and cross-link your identities with sameAs. It isn't a magic ranking mechanism, but it costs little to maintain and makes your site's identity easier for machines to understand.
  9. Submit the sitemap. Check the Search Console property is a Domain property.
  10. If the domain had a previous life, set up 301 redirects from the old URL structure so whatever link equity survived still lands somewhere real.
  11. Then wait. The rest is time and links.

I spent four months assuming Google was just being, you know, Google. It turns out I had given it six empty pages and expected it to figure the rest out.

Top comments (0)