Embedding a Vue 3 app in a Go binary with go:embed — and keeping SEO without SSR
"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.
1. Embed the build
// 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.
2. Serve the SPA without lying about 404s
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.- Server-rendered pages that must be real files to crawlers:
/blog,/blog/{slug},/blog/feed.xml,/sitemap.xml,/robots.txt. An unknown slug renders the blog's own 404 page with status 404. - Everything else — the SPA handler: a static asset if the path exists in
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.
3. Prerender the public pages at build time
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:
- it serves
dist/throughvite previewand drives each route fromprerender.routes.json(English and French twins) with the Playwright Chromium; - every request to another origin is aborted — an analytics script must not record a build as a visit, and the build must not depend on a third-party host being up;
/api/**is stubbed (/api/auth/me→ 401, anything else → 404), so the captured page is exactly what an anonymous visitor sees;- it fails loudly: a route that rendered under 500 characters, or that has no canonical tag, breaks the build instead of embedding an empty shell;
- the captured HTML lands in
dist/prerendered/<route>/index.html.
The Go server loads that directory at startup into a map from route to
bytes. A route present in the map is served before the shell; a build
without the directory gets an empty map and behaves as a plain SPA. The
whole feature is one build flag (PRERENDER=1 in the Docker build).
4. The origin problem, and the placeholder
Canonicals and hreflang links are absolute URLs. At build time the app
runs on http://localhost:4173; in production it runs on whatever
BASE_URL the operator configured. Baking the production origin into the
build would make the image deployment-specific, which is exactly what a
signed, published image must not be.
So the prerenderer rewrites its own origin to a placeholder,
__BASE_URL__, and the server substitutes it once at startup while
loading the pages:
pages[route] = bytes.ReplaceAll(b, []byte("__BASE_URL__"), []byte(baseURL))
The same image serves https://govuekit.dev and https://demo.example.com
with correct canonicals on both. The e2e suite asserts that no
__BASE_URL__ survives in a served page.
5. Hydration, hreflang and the sitemap
The Vue app mounts on top of the prerendered DOM, so a browser gets content
first and interactivity a moment later; the head tags the app manages
(title, description, canonical, the hreflang pair for /x and /fr/x,
x-default on English) are written by a small useSeoHead composable and
captured with the page. The sitemap is derived from the set of prerendered
routes plus the blog posts with their lastmod, so a route added to
prerender.routes.json is indexed without touching Go.
6. What this does not do
It does not render per-user or per-request content for crawlers: the dashboard is not prerendered, and should not be. It does not replace a content-heavy marketing site with thousands of pages; for that, a static site generator next to the binary is the right tool. And it does not prerender the blog, because the blog is already server-rendered by Go from embedded markdown — the other half of the "content in the binary" story.
Try it
GoVueKit's own sales site is built this way: curl -sI https://govuekit.dev/fr
shows a real HTML document with a canonical and two hreflang links before
any JavaScript runs. The mechanism, the knobs and the reasons live in
docs/SEO.md of the kit, and the whole product runs locally from one file:
curl -fsSLO https://govuekit.dev/labs/docker-compose.yml
docker compose up -d