I recently moved a small interactive content site from a Next.js runtime to a single Go service. The motivation was operational simplicity: the product needed a polished React interface and indexable content pages, but it did not need a Node server in production.
The result uses Go’s standard net/http stack, an embedded Vite build, SQLite infrastructure, and build-time prerendering. This post covers the boundaries that made the migration manageable and the tradeoffs that remain.
The site is a deterministic planner. Users choose a few options, compare two generated sequences, edit them, download a PNG, or share a URL. The calculation can happen entirely in the browser.
That meant I could separate the system into two clear parts: React owns the interactive product state and rendering. Go owns HTTP delivery, configuration, security headers, small API endpoints, and production integration points.
There was no reason to turn every user interaction into a server round trip. Recent planner choices stay in localStorage, while a validated query string carries the state needed for a share link.
The frontend is built with React 19 and Vite. Production does not run Vite. Instead, Go embeds the finished assets:
The HTTP server opens the embedded subdirectory and serves hashed assets with long cache lifetimes. HTML uses a shorter policy so metadata and content changes can ship predictably.
This keeps the deployment artifact small in concept: one binary, its environment file, CA certificates, timezone data, and a writable data directory. Node is still part of the build toolchain, but not the production runtime.
A client-rendered shell would have weakened the content pages, so the build produces complete HTML for every indexable route. Each route gets its own title, description, canonical URL, Open Graph fields, and page body.
The prerender list includes the landing page, generator, style guides, eye-shape guides, methodology and safety pages, and printable template guide. A build should fail if a new indexable route is added without metadata or prerender output.
The Go server substitutes the configured production origin into canonical fields. That avoids baking a staging hostname into a release while keeping generated HTML deterministic.
The generator’s state needed to survive three paths: defaults created by the current product version; a saved local state from the same browser; a shared URL received from another person.
I use one normalization layer for all three. Unknown values are rejected, lengths are constrained to the supported range, cluster counts are bounded, and a custom map must match the normalized count and available lengths.
The same module creates the outbound query string. A round-trip test verifies that a state encoded for sharing restores to the same normalized map.
That test matters more than the framework choice. Without it, a share button can appear to work while silently changing a recipient’s map.
The download feature draws the current map to a canvas and exports a 1200×675 PNG. Native sharing uses the Web Share API when the browser supports files, then falls back to sharing or copying the URL.
This is another task that did not require a backend rendering service. Keeping it local reduces infrastructure and avoids uploading a user’s work merely to create an image.
The Go template includes SQLite in WAL mode for product features that may need persistence later. The current planner does not write map data to it. Having a database available does not mean every piece of browser state belongs there.
