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 product boundary came first
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.
Build React once, embed the output
The frontend is built with React 19 and Vite. Production does not run Vite. Instead, Go embeds the finished assets:
package webassets
import "embed"
//go:embed dist/*
var Dist embed.FS
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.
Preserve route-level HTML
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.
Keep the interactive state in one module
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.
const query = generatorSearchParams(state, activeMap, selectedPlan)
const restored = generatorStateFromSearch(query, defaults)
assert.deepEqual(restored.customMaps[selectedPlan], activeMap)
That test matters more than the framework choice. Without it, a share button can appear to work while silently changing a recipient’s map.
Generate downloadable images in the browser
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.
SQLite is infrastructure, not an excuse to collect data
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.
The same principle applies to authentication adapters. The template has an optional Google sign-in boundary, but the public planner does not require an account. Disabled infrastructure stays disabled until a feature has a real need, a tested flow, and matching privacy documentation.
Consent-aware analytics
Analytics loads only after the visitor chooses to allow it. Before consent, the site keeps only bounded attribution values for the current tab. Product events describe actions such as the first interaction, download, print, or share; they do not include exact map sequences, email addresses, or photos.
This required more work than dropping a script into the document head, but it made the public privacy explanation match the code path.
Deployment and rollback
The production process is intentionally boring:
- build the Vite frontend;
- prerender indexable routes;
- run frontend tests;
- run Go tests, race checks, and vet;
- compile one Linux binary;
- deploy it behind the existing TLS reverse proxy and systemd service.
Releases use an explicit environment value for the public base URL. The previous binary remains available for rollback. SQLite data lives outside the release directory even though the current product does not write planner state.
Tradeoffs
This architecture is not a universal replacement for Next.js. It works here because the interactive state is local, the content routes are known at build time, and the server-side needs are small.
It would be a worse fit if the product depended on per-request React server rendering, a large ecosystem of server components, or many dynamic pages that must be rendered with authenticated data.
The migration also creates responsibility. We own the prerender pipeline, route manifest, caching behavior, and security headers. Framework defaults no longer hide mistakes, so tests must enforce those contracts.
The useful lesson
The biggest memory saving was conceptual before it was technical: decide which data truly needs a server. Once the planner, image export, and share-state encoding were treated as browser responsibilities, the production server became a compact delivery and integration layer.
The live result is Lash Map Lab, a free lash-map planner. The subject is niche, but the architecture applies to many small interactive SEO sites: prerender the known content, keep deterministic tools local, and deploy only the runtime the product actually needs.
Disclosure: I’m Julian Tao, Owner of Lash Map Lab. This article describes my own product. It was prepared with AI-assisted editing and reviewed for technical accuracy before publication.
Top comments (0)