GoVueKit

Embedding a Vue 3 app in a Go binary with go:embed — and keeping SEO without SSR

September 9, 2026

"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:

  1. /api/... — JSON handlers; an unknown API path answers a JSON 404.
  2. 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.
  3. 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:

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