Embedding a Vue 3 app in a Go binary with go:embed — and keeping SEO without SSR A developer built GoVueKit, a Go project that embeds a Vue 3 single-page app into a single binary using go:embed while preserving SEO through build-time prerendering instead of server-side rendering. The prerenderer drives public routes with Playwright Chromium against a stubbed API, captures anonymous-visitor HTML, and the Go server substitutes a __BASE_URL__ placeholder at startup so one image serves multiple origins with correct canonicals. Unknown blog slugs return real 404s and only application routes fall back to the SPA shell, avoiding the duplicate-content problem of serving index.html for every path. "One binary" is easy to say and easy to get half right. Serving dist/ from Go takes six lines; serving it so that deep links work, real 404s stay 404s, crawlers see content, canonicals point at the right origin and the build stays deployment-agnostic takes a few decisions. This is how GoVueKit does it, with the code, and the reasons. js // web/embed.go package web import "embed" //go:embed all:dist var Dist embed.FS Two details matter. all: includes dotfiles, so a .gitkeep keeps dist/ present in a fresh clone and the embed directive never fails to match. And the frontend is built before go build , never at runtime: the Makefile runs npm ci && vite build first, the Dockerfile has a Node stage whose only output is web/dist , and the final image is FROM scratch with the binary in it. Node exists at build time and nowhere else. The naïve handler serves index.html for every unknown path. That is also how you end up with /blog/does-not-exist returning a 200 with an empty shell, which search engines index as duplicate content. GoVueKit mounts routes in this order: /api/... — JSON handlers; an unknown API path answers a JSON 404. /blog , /blog/{slug} , /blog/feed.xml , /sitemap.xml , /robots.txt . An unknown slug renders the blog's own 404 page with status 404. dist/ , the prerendered HTML if the route was captured below , the shell otherwise. The shell is the fallback for application routes /dashboard , /orgs/… , where the router owns the URL and a guest gets bounced to /login . Nothing indexable relies on it. SSR would solve SEO and cost a Node process in production, which defeats the binary. Prerendering solves it at build time instead: the public pages are static enough landing, pricing, legal, contact that the HTML a crawler needs is the HTML an anonymous first visit renders. web/scripts/prerender.mjs runs after vite build : vite preview and drives each route from prerender.routes.json English and French twins with the Playwright Chromium; /api/ is stubbed /api/auth/me → 401, anything else → 404 , so the captured page is exactly what an anonymous visitor sees; dist/prerendered/