All articles
02 · Search & GEO · 2026.10 · 5 min

Making a Vue SPA visible to search engines and AI crawlers

A single-page app shows an empty div to any crawler that does not run JavaScript. On this site every route becomes real HTML in two languages at build time; hreflang, JSON-LD, the sitemap, robots.txt and llms.txt come from the same content, and the build checks itself at the end.

01The empty div problem

The HTML a Vue SPA gets from the server is almost empty: a <div id="app"> and a script tag. The content appears after the browser runs the JavaScript. Google often renders that, but the page fetchers of AI assistants and many preview bots read the page without running JavaScript. For them, this site was nothing but a boot screen and an empty box.

One option was to move the site to an SSR-first framework. Instead, I kept the existing Vue app and added a prerender step to the build. The site has no server side; every page's content is known at build time. In that case static HTML files are enough.

02One app, two entries

The shared point is src/app/createSiteApp.ts. On the server, or when hydrating, it uses createSSRApp; otherwise plain createApp. The router uses memory history on the server and web history in the browser. The client entry, main.ts, checks whether #app contains an element: on a prerendered page it does, and the app hydrates; on the dev server #app only holds a comment, and the app mounts from scratch.

src/app/main.ts
const container = document.getElementById('app')!
const hydrate = !!container.firstElementChild
const { app, router } = createSiteApp({ server: false, hydrate })
app.use(createHead())

// wait for the first route (lazy views) before mounting
router.isReady().then(() => {
  app.mount(container)
  restoreSecret()
  bootDone()
})

Waiting for router.isReady matters: most views are lazy-loaded, and mounting before the first route resolves would produce a tree that does not match the HTML the server wrote. On the server, the render function in entry-server.ts does the same job the other way round: it goes to the URL with router.push, waits for isReady, produces HTML with renderToString and puts it in place of <!--app-html--> in the template. Head tags are merged with unhead's transformHtmlTemplate. The same module also exports the projects, articles and language files, so the build scripts read content from the app itself rather than a second source.

03One file per route

The build command runs, in order, the type check, the client build, the SSR build, the prerender and the SEO check. scripts/prerender.mjs walks the whole publicPaths list for both languages. That list is derived in router.ts from the projects and articles; adding an article means two new HTML files. The 404 page is written separately, with noindex.

build output
dist/index.html                 → /
dist/work/yee-cms.html          → /work/yee-cms
dist/en.html                    → /en
dist/en/journal/<article>.html  → /en/journal/<article>
dist/404.html                   → noindex

04Two languages, two URLs

Turkish pages live at the root, English pages under /en. router.ts generates the English routes as copies of the Turkish ones, prefixes their names with en- and sets meta.lang to "en"; the catch-all 404 routes go last. The language is read from the URL, and the setLocale call in beforeEach runs on the server as well as in the browser. That is why the prerendered HTML and the page after hydration speak the same language.

useSeo gives every page a self-referencing canonical and hreflang links for tr, en and x-default; x-default points at the Turkish page. og:locale becomes tr_TR or en_US, and the other language is written as og:locale:alternate. The 404 page is noindex, so it carries neither a canonical nor language alternates; it should not point search engines anywhere.

05The layer machines read

Every page has a single JSON-LD block with an @graph inside. The Person and WebSite nodes are on every page; Person has a fixed @id, so all pages point at the same person. Page-specific nodes are added on top: BlogPosting for articles, BreadcrumbList for subpages, CollectionPage for the sitemap page. Before the JSON is written into the script tag, < characters are escaped, so no text in the content can close the tag early.

sitemap.xml lists every address in both languages; each entry has xhtml:link alternates for tr, en and x-default, and the page's share card as image:image. robots.txt is open to everyone and names the bots in three groups: search engines (Googlebot, Bingbot, Applebot, DuckDuckBot, YandexBot), AI search and assistants that cite sources (OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, PerplexityBot, Perplexity-User), and training crawlers and control tokens (GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, CCBot). Each group gets an explicit Allow; if I want to opt out, changing one word to Disallow is enough. The sitemap address sits at the end of the file.

llms.txt is a plain markdown map in the llmstxt.org format: a short description, then pages, projects and articles in both languages. llms-full.txt gives the full text of every article and project note in both languages, each with its own URL; article blocks are turned into markdown headings, quotes and code blocks. All of these files are generated in scripts/seo-files.mjs from the app's own content module.

SEO should not be a setting you make once, but a test that runs on every build.

06Let the build check itself

The last step is scripts/check-seo.mjs. In every HTML file under dist it looks for exactly one <title> and exactly one <h1>, checks that <html lang> matches the file's path, that og:locale matches the language and that the required meta set is complete. The description must be between 50 and 170 characters. The canonical must be in the sitemap. hreflang links must be self-referencing, x-default must go to the Turkish page, and the page in the other language must link back. og:image and twitter:image must be absolute, the file must exist and it must be 1200×630. JSON-LD must parse, and BlogPosting, BreadcrumbList and Person nodes must carry their required fields. robots.txt must advertise the sitemap. If anything is wrong, the build fails.

Related articles
  1. 03A separate share card for every page and language, drawn with SatoriSearch & GEO2026.106 min
  2. 04Syne's g: not a bug but the design, and I still replaced two lettersDesign & interaction2026.105 min