2026-10-10 · 3 min read

Making a Vue single-page app readable to search engines

Why my portfolio shipped an empty HTML shell, and how static prerendering with vite-ssg fixed it without moving to a server.

When I applied to Google AdSense with this portfolio, the application was rejected for "low value content". The text on the page was real, so it took me a while to look at what a crawler actually receives. The answer was a document with an empty <div id="app"></div>.

The problem with a client-rendered SPA

A Vue single-page app built with Vite ships an index.html that contains almost nothing. The content appears only after the JavaScript bundle runs and mounts the app. Google can execute JavaScript, but it does so in a second, delayed pass and not always reliably, and many other consumers (link previews, simple crawlers, accessibility tools, people with scripts blocked) never do.

Two details made it worse on this site:

  • Everything lived on a single URL, so there was only one page to judge.
  • GitHub Pages was serving a copy of index.html as 404.html, so every broken URL returned the same empty shell.

The fix: prerender at build time

I did not need a server. The site is hosted on GitHub Pages, so the goal was to keep it static but make each route a real HTML file. vite-ssg does exactly that: it builds the app twice, once for the browser and once for Node, renders every route to HTML, and writes the result to dist.

The pieces I had to add:

  1. A router. Each page became a route with vue-router, and the entry point switched from createApp to ViteSSG.
  2. Per-page metadata. Title, description and canonical URL are set from each page with useHead from @unhead/vue, so they end up in the prerendered HTML. I removed the static copies from index.html, otherwise every page would have two canonical links.
  3. Dynamic routes listed explicitly. Routes with parameters, like /projects/:slug, cannot be discovered automatically, so includedRoutes in the Vite config lists them from the same data the pages use.
  4. A real 404. I added a /404 route, prerendered it, and copy it to dist/404.html in the deploy workflow. GitHub Pages serves that file for unknown URLs, and the page is marked noindex.

Things that bite

  • Browser-only code. Anything that touches window or document at setup time crashes the Node render. Moving that code into onMounted (or guarding it) fixes it, and it also avoids hydration mismatches.
  • State that depends on the visitor. My language switch is remembered in localStorage. If I read it during setup, the server renders one language and the browser expects another. The page is always prerendered in English and the saved choice is applied after mount.
  • Trailing slashes. With dirStyle: 'nested' each route becomes route/index.html, which GitHub Pages serves at /route/. I use the trailing-slash form everywhere: canonicals, sitemap and internal links.

How to check it worked

The simplest test is the one I skipped at first: disable JavaScript, or fetch the page with curl, and look at the HTML. If you can read the headings and paragraphs there, so can a crawler.

curl -s https://wxlter.dev/projects/ | grep -o '<h1[^>]*>[^<]*'

Prerendering does not make thin content valuable, but it removes a whole class of "the crawler saw nothing" problems, which was the right place to start.

vueseovitegithub-pages

← All posts