cd /news/developer-tools/embedding-a-vue-3-app-in-a-go-binary… · home › topics › developer-tools › article
[ARTICLE · art-147401] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

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.

by read4 min views2 publishedOct 8, 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.

// 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/<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).

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

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.

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.

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.

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

Originally published on govuekit.dev. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one docker compose up: https://govuekit.dev/labs

── more in #developer-tools 4 stories · sorted by recency
── more on @govuekit 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/embedding-a-vue-3-ap…] indexed:0 read:4min 2026-10-08 · —