{"slug": "embedding-a-vue-3-app-in-a-go-binary-with-go-embed-and-keeping-seo-without-ssr", "title": "Embedding a Vue 3 app in a Go binary with go:embed — and keeping SEO without SSR", "summary": "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.", "body_md": "\"One binary\" is easy to say and easy to get half right. Serving `dist/` from\n\nGo takes six lines; serving it so that deep links work, real 404s stay 404s,\n\ncrawlers see content, canonicals point at the right origin and the build\n\nstays deployment-agnostic takes a few decisions. This is how GoVueKit does\n\nit, with the code, and the reasons.\n\n``` js\n// web/embed.go\npackage web\n\nimport \"embed\"\n\n//go:embed all:dist\nvar Dist embed.FS\n```\n\nTwo details matter. `all:` includes dotfiles, so a `.gitkeep` keeps `dist/`\n\npresent in a fresh clone and the embed directive never fails to match. And\n\nthe frontend is built **before** `go build`, never at runtime: the\n\n`Makefile` runs `npm ci && vite build` first, the Dockerfile has a Node stage\n\nwhose only output is `web/dist`, and the final image is `FROM scratch` with\n\nthe binary in it. Node exists at build time and nowhere else.\n\nThe naïve handler serves `index.html` for every unknown path. That is also\n\nhow you end up with `/blog/does-not-exist` returning a 200 with an empty\n\nshell, which search engines index as duplicate content. GoVueKit mounts\n\nroutes in this order:\n\n`/api/...` — JSON handlers; an unknown API path answers a JSON 404.`/blog`,\n`/blog/{slug}`, `/blog/feed.xml`, `/sitemap.xml`, `/robots.txt`. An\nunknown slug renders the blog's own 404 page with status 404.`dist/`, the prerendered HTML if the route was captured (below), the\nshell otherwise.\nThe shell is the fallback for **application** routes (`/dashboard`,\n\n`/orgs/…`), where the router owns the URL and a guest gets bounced to\n\n`/login`. Nothing indexable relies on it.\n\nSSR would solve SEO and cost a Node process in production, which defeats\n\nthe binary. Prerendering solves it at build time instead: the public pages\n\nare static enough (landing, pricing, legal, contact) that the HTML a crawler\n\nneeds is the HTML an anonymous first visit renders.\n\n`web/scripts/prerender.mjs` runs after `vite build`:\n\n`vite preview` and drives each route from\n`prerender.routes.json` (English and French twins) with the Playwright\nChromium;`/api/**` is stubbed (`/api/auth/me` → 401, anything else → 404), so the\ncaptured page is exactly what an anonymous visitor sees;`dist/prerendered/<route>/index.html`.\nThe Go server loads that directory at startup into a map from route to\n\nbytes. A route present in the map is served before the shell; a build\n\nwithout the directory gets an empty map and behaves as a plain SPA. The\n\nwhole feature is one build flag (`PRERENDER=1` in the Docker build).\n\nCanonicals and `hreflang` links are absolute URLs. At build time the app\n\nruns on `http://localhost:4173`; in production it runs on whatever\n\n`BASE_URL` the operator configured. Baking the production origin into the\n\nbuild would make the image deployment-specific, which is exactly what a\n\nsigned, published image must not be.\n\nSo the prerenderer rewrites its own origin to a placeholder,\n\n`__BASE_URL__`, and the server substitutes it **once at startup** while\n\nloading the pages:\n\n```\npages[route] = bytes.ReplaceAll(b, []byte(\"__BASE_URL__\"), []byte(baseURL))\n```\n\nThe same image serves `https://govuekit.dev` and `https://demo.example.com`\n\nwith correct canonicals on both. The e2e suite asserts that no\n\n`__BASE_URL__` survives in a served page.\n\nThe Vue app mounts on top of the prerendered DOM, so a browser gets content\n\nfirst and interactivity a moment later; the head tags the app manages\n\n(title, description, canonical, the `hreflang` pair for `/x` and `/fr/x`,\n\n`x-default` on English) are written by a small `useSeoHead` composable and\n\ncaptured with the page. The sitemap is derived from the set of prerendered\n\nroutes plus the blog posts with their `lastmod`, so a route added to\n\n`prerender.routes.json` is indexed without touching Go.\n\nIt does not render per-user or per-request content for crawlers: the\n\ndashboard is not prerendered, and should not be. It does not replace a\n\ncontent-heavy marketing site with thousands of pages; for that, a static\n\nsite generator next to the binary is the right tool. And it does not\n\nprerender the blog, because the blog is already server-rendered by Go from\n\nembedded markdown — the other half of the \"content in the binary\" story.\n\nGoVueKit's own sales site is built this way: `curl -sI https://govuekit.dev/fr`\n\nshows a real HTML document with a canonical and two `hreflang` links before\n\nany JavaScript runs. The mechanism, the knobs and the reasons live in\n\n`docs/SEO.md` of the kit, and the whole product runs locally from one file:\n\n```\ncurl -fsSLO https://govuekit.dev/labs/docker-compose.yml\ndocker compose up -d\n```\n\n*Originally published on [govuekit.dev](https://govuekit.dev/blog/embed-vue-app-in-go-binary-seo). 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](https://govuekit.dev/labs)*", "url": "https://wpnews.pro/news/embedding-a-vue-3-app-in-a-go-binary-with-go-embed-and-keeping-seo-without-ssr", "canonical_source": "https://dev.to/benjy33000/embedding-a-vue-3-app-in-a-go-binary-with-goembed-and-keeping-seo-without-ssr-1p2m", "published_at": "2026-10-08 07:07:04+00:00", "updated_at": "2026-10-08 07:17:07.564680+00:00", "lang": "en", "topics": ["developer-tools", "ai-search", "structured-data"], "entities": ["GoVueKit", "Vue 3", "Go", "Playwright", "Vite", "Docker"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/embedding-a-vue-3-app-in-a-go-binary-with-go-embed-and-keeping-seo-without-ssr", "markdown": "https://wpnews.pro/news/embedding-a-vue-3-app-in-a-go-binary-with-go-embed-and-keeping-seo-without-ssr.md", "text": "https://wpnews.pro/news/embedding-a-vue-3-app-in-a-go-binary-with-go-embed-and-keeping-seo-without-ssr.txt", "jsonld": "https://wpnews.pro/news/embedding-a-vue-3-app-in-a-go-binary-with-go-embed-and-keeping-seo-without-ssr.jsonld"}}