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

> Source: <https://dev.to/benjy33000/embedding-a-vue-3-app-in-a-go-binary-with-goembed-and-keeping-seo-without-ssr-1p2m>
> Published: 2026-10-08 07:07:04+00:00

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

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.

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](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)*
