Why Datastar Over React
Why Lodestone's web frontend uses server-rendered HTML and SSE instead of a JavaScript framework
Table of Contents
The Decision
When I started building Lodestone's web frontend, I had a choice to make. The app already had a Go backend serving a REST API to an iOS client. The conventional move would be to spin up a React (or Next.js, or SvelteKit) frontend, hit the same API, and call it a day. That's what most people would do, and it would work fine.
I didn't do that.
Instead, I went with Datastar — a ~14 KB JavaScript library that enables server-rendered HTML with real-time reactivity via Server-Sent Events. The web frontend is rendered entirely by Go templates, compiled at build time, and served by the same binary that runs the API. There is no separate frontend application. There is no Node.js build step for the UI. There is no React.
This was a deliberate choice, not a hipster flex. Let me explain why.
What Datastar Actually Is
Datastar is not a framework. It's a thin client-side library that does two things:
- Declarative reactivity — HTML attributes like
data-bind,data-on:click, anddata-showlet you wire up interactivity directly in your markup. If you've used Alpine.js, the mental model is similar. - SSE-based server communication — instead of fetching JSON and rendering it client-side, the browser sends a request to the server, and the server streams back HTML fragments that replace parts of the page. The server decides what changes. The client just swaps DOM nodes.
The critical insight is that the server is the source of truth for everything — data, state, validation, and rendering. The browser is a thin terminal that displays whatever HTML the server sends it.
How It Works in Practice
Here's the flow for a typical interaction in Lodestone. Say a user rates a hiking route on the discover page.
The template renders a button with a Datastar directive:
<button
data-on:click__stop={ fmt.Sprintf("@post('/ds/app/routes/%d/save')", route.ID) }
title="Save to collection"
>
<i class="ph ph-bookmark-simple"></i>
</button>
When the user clicks, Datastar sends an SSE request to that URL. On the server, a Go handler receives it:
func DatastarRateRouteHandler(w http.ResponseWriter, r *http.Request) {
var signals struct {
Rating int `json:"routerating"`
}
datastar.ReadSignals(r, &signals)
sse := NewSSE(w, r)
ctx := WrapSSENoAuth(sse, r)
routeID, _ := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
_, err = routeService.RateRoute(ctx.UserID, uint(routeID), signals.Rating)
if err != nil {
ctx.ExecuteScript(`alert("Failed to save rating")`)
return
}
// Re-render and patch just the rating section
route, _ := routeService.GetPublicRoute(uint(routeID))
ctx.PatchElements(RenderTempl(cards.RouteRatingSection(NewRouteRatingData(route, signals.Rating))))
}
The handler reads the client's signal data, calls the service layer, then patches the relevant HTML fragment back into the page. No JSON serialization. No client-side state update. No re-render cycle. The server rendered the new HTML, streamed it over SSE, and Datastar swapped it into the DOM.
That's it. That's the whole pattern.
The Template System
The templates are written in templ, a Go templating language that compiles to Go code at build time. Each component is a strongly-typed Go function:
templ DiscoverSaveButton(data DiscoverSaveButtonData) {
if data.IsSaved {
<button
data-on:click__stop={ fmt.Sprintf("@delete('/ds/app/routes/%d/save')", data.RouteID) }
title="Remove from saved"
>
<i class="ph-fill ph-bookmark-simple text-emerald"></i>
</button>
} else {
<button
data-on:click__stop={ fmt.Sprintf("@post('/ds/app/routes/%d/save')", data.RouteID) }
title="Save to collection"
>
<i class="ph ph-bookmark-simple"></i>
</button>
}
}
The data struct is defined in Go. The template is compiled and type-checked at build time. If I pass the wrong type, it won't compile. If I reference a field that doesn't exist, it won't compile. This is a level of safety that JSX can approximate with TypeScript, but templ gets it for free because it is Go.
Lodestone currently has 83 templ files totaling over 20,000 lines of template code. That's a substantial frontend — settings pages, trip planning, gear management, social features, route discovery with maps, admin dashboards — all rendered server-side.
What the JavaScript Bundle Actually Contains
Lodestone's entire JS bundle is 58 KB minified. Here's what's in it:
- Maps — Mapbox GL initialization, terrain rendering, markers, 3D flyover visualization
- Charts — Chart.js wrappers for elevation profiles and weight breakdowns
- Payments — Stripe checkout and customer portal integration
- Utilities — haversine distance, unit conversion constants, HTML escaping
- SPA navigation — view transition state management
- Date picker, gallery modal, toast notifications
That's 21 TypeScript modules, all domain-specific. Not a single one is a UI framework, a state management library, a routing layer, or a virtual DOM implementation. Every byte of JavaScript in the bundle exists because the domain requires it — you can't render a Mapbox map without JavaScript. You can't initialize a Stripe checkout session without JavaScript.
The UI itself? Zero JavaScript. Datastar handles reactivity, and it's loaded as a 14 KB ES module from a CDN.
What React Would Cost
Let's be honest about what a React frontend would look like for Lodestone.
Bundle size. React + ReactDOM is ~44 KB minified and gzipped. Add a router (React Router, ~14 KB), a state management library (Zustand or Redux Toolkit, ~5-15 KB), a form library, and suddenly you're at 80-100 KB before you've written a single line of application code. Then add your code — 83 pages worth of components, hooks, context providers, API clients. A conservative estimate for Lodestone's feature set would be 150-250 KB total bundle size, compared to the 58 KB I ship today (which, again, is almost entirely maps and charts that React would also need).
The API contract layer. This is the one that really gets me. With React, I'd need a formal JSON API for every interaction the web frontend performs. Lodestone already has a REST API for the iOS client, but the web frontend has different needs — different data shapes, different composition patterns, different auth flows. I'd either end up duplicating endpoints or building a BFF (backend-for-frontend) layer.
With Datastar, there is no API contract for the web frontend. The handler reads signals, does work, and sends back HTML. The "contract" is the HTML itself. If I change the response shape, I change the template — they're the same file, in the same language, in the same binary.
State management. In React, every piece of server data needs to be fetched, cached, synchronized, and invalidated on the client. You need React Query or SWR or a similar library just to keep client state from drifting away from server state. With Datastar, there is no client state to manage. The server is the state. When something changes, the server sends new HTML. There's nothing to invalidate because there's nothing cached.
Build complexity. Lodestone compiles to a single Go binary. go build and you're done. A React frontend would add Node.js, npm/yarn/pnpm,
webpack/Vite/esbuild, TypeScript compilation, and a dev server to the toolchain. That's not insurmountable, but it's complexity that exists
purely to serve the frontend framework — it doesn't make the product better.
The Tradeoffs
I'm not going to pretend this is all upside. Datastar has real costs.
Ecosystem. React has thousands of component libraries. Need a date picker? A rich text editor? A drag-and-drop kanban board? There's a package for that, probably several, and they'll work with your existing React code. With Datastar, I either build these things myself, find a vanilla JS library and integrate it manually, or go without. The date picker in Lodestone? I wired up a vanilla JS library. The gallery modal? I wrote it from scratch.
Hiring. If Lodestone ever grows to the point where I need to hire frontend developers, the talent pool for "Go + templ + Datastar" is approximately zero. Every bootcamp graduate knows React. Very few people have heard of Datastar. This is a real constraint, and I'm aware of it.
Community and documentation. React has a decade of Stack Overflow answers, blog posts, conference talks, and tutorials. Datastar is young and small. When I hit a wall, I'm reading source code, not searching for answers on the internet. The documentation is solid but sparse.
Client-side interactivity ceiling. Highly interactive UIs — drag-and-drop, complex animations, collaborative editing — are harder with Datastar's model. Every interaction round-trips to the server. For Lodestone, this isn't a problem because the app is fundamentally CRUD + maps. But if I were building Figma, I wouldn't be writing this article.
Why It Works for Lodestone
Datastar works for Lodestone because of several properties that may not apply to your project:
Solo developer. I maintain the entire stack. Having the frontend and backend in the same language, the same binary, and the same deployment pipeline reduces my cognitive overhead dramatically. I don't context-switch between Go and TypeScript. I don't maintain two build systems. I don't debug mismatches between the API contract and the client's expectations of it.
Server-side truth. Lodestone's data model is authoritative on the server. Gear weights, trail distances, elevation profiles — these are computed and stored server-side. The frontend is a view of that data, not an independent application that happens to share a database. Datastar's model of "server renders, client displays" maps perfectly onto this reality.
Single binary deployment. The Go binary embeds all templates, static assets, and the JS bundle. I docker build, push to my registry,
and Nomad rolls out the new version. There's no CDN to invalidate, no static asset hosting to configure, no CORS headers to debug.
CRUD-dominant UI. Most of Lodestone's web interactions follow the same pattern: user clicks a thing, server does work, server sends back updated HTML. Rate a route, add gear to a loadout, accept a friend request, update trip settings — they're all the same flow. Datastar handles this pattern with almost no boilerplate.
200 SSE handlers and counting. Lodestone's web frontend currently has around 200 Datastar handler functions across 25 files. Each one follows the same pattern: read signals, validate, call service, patch HTML. It's boring, repetitive, and incredibly easy to maintain. When I come back to a handler I wrote six months ago, I can read it in ten seconds because there's no abstraction to untangle.
The Bottom Line
The web is not a single-page application platform that we've all agreed to treat as one. It's a document delivery system with a really good rendering engine. Datastar leans into that reality instead of fighting it.
For Lodestone, this means a 58 KB bundle, a single deployment artifact, type-safe templates, and a frontend that is fundamentally just HTML — rendered by the same server that owns the data. It's simpler, it's faster to develop, and it's one less thing that can break at 2 AM.
React is a fine technology. I'd use it for a collaborative document editor, a real-time dashboard with client-side computations, or a project where I needed to hire five frontend engineers tomorrow. But for a hiking app maintained by one person who'd rather be on a trail than debugging hydration mismatches? Datastar was the right call.