via builds server-rendered web UI from Go structs. A page is a struct, its actions are methods, and the browser runs a small client via ships, so there is no JavaScript to write and no build step.
typeWhyQuotestruct{Qtyvia.Signal[int]`via:"init=1"`Totalvia.State[int]}func(q*WhyQuote)Price(ctx*via.Ctx){// Qty came from the browser, so it is input like any form field.q.Total.Set(max(q.Qty.Get(),0)*12)}func(q*WhyQuote)View()h.H{returnh.Div(h.Class("row"),h.Input(h.Type("number"),h.Min(0),q.Qty.Bind()),h.Button(on.Click(q.Price),h.Str("Price")),h.Output(q.Total.Display()),)}
Part
What it becomes
Qty
A signal in the browser. The input edits it with no request; its value rides along on every action POST. See via.Signal.
Total
A value held on the server for this tab. Rendering it with via.State.Display makes the unit live, so the tab opens one SSE stream.
Price
An action: a POST endpoint via addresses by the method itself. There is no route to register.
on.Click(q.Price)
on.Click takes a method value, so renaming or deleting Price is a compile error, not a dead button.
View
The HTML. It runs again after each action, and via patches the changed element into the page.
The code above, running. The quantity is a browser value until you press Price; the total comes back as a patch on the stream.
Try this
Change the quantity: the Signals box follows it and no request is sent.
Press Price: one POST, then one patch that re-renders the whole unit, <output> included.
Type -5 and press Price: the action clamps it, because a signal is client input.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on")typeWhyQuotestruct{Qtyvia.Signal[int]`via:"init=1"`Totalvia.State[int]}func(q*WhyQuote)Price(ctx*via.Ctx){// Qty came from the browser, so it is input like any form field.q.Total.Set(max(q.Qty.Get(),0)*12)}func(q*WhyQuote)View()h.H{returnh.Div(h.Class("row"),h.Input(h.Type("number"),h.Min(0),q.Qty.Bind()),h.Button(on.Click(q.Price),h.Str("Price")),h.Output(q.Total.Display()),)}
The compiler checks the wiring. Event bindings are method values and signal names come from struct fields, so no string ties markup to a handler. The optional hooks (OnInit, OnReload, PageMeta) are duck-typed: a wrong signature panics at via.Mount, and a near-miss name is logged once.
Plain pages stay plain HTTP. A page streams only if it holds a live unit: one that renders a via.State or via.List, or registers via.Ctx.Tick or via.Ctx.Listen. Otherwise a GET is a response and an action is a POST, with no connection held. Live state has the rules.
Two kinds of state, picked by field type. via.Signal lives in the browser and travels with each action. via.State and via.List live on the server, one copy per tab, and never reach the client as data.
Shared state is your store plus a topic. via holds no global state. Keep the shared value in your own store and publish changes on a topic.Topic; each tab's unit follows it with via.State.Track or via.Ctx.Listen, on the unit's own goroutine, so handlers need no locks.
No JavaScript and no build step. The Datastar client is embedded in the via module and served by the router. When a view needs a JS library, an island hands it one subtree.
Only the standard library at run time. via's module requires testify, for its own tests, and staticcheck, as a tool. Neither is built into your program.
Memory and a goroutine per live tab. Each open tab with a live unit keeps its composition in memory and one goroutine that runs every live unit on it, plus one per Tick timer. Capacity scales with open tabs, not request rate. via.WithMaxSSEConn caps streams (default 10,000) and refuses the next with a 503. The repo publishes no per-tab memory figure: it is the size of your structs.
One SSE connection per live tab. Proxies must pass long-lived streams without buffering, and each stream holds a file descriptor. Push is SSE only; there is no WebSocket transport.
A data-* attribute is script. Datastar compiles every expression in the page under its nonce, so the CSP cannot stop an injected one; Sessions & security says what that costs.
One process unless you bridge. A tab lives on the process that opened its stream, and a topic fans out inside one process. More than one instance needs sticky routing and your own bus between them; Deploy shows both.
Coupled to Datastar. via does not abstract over its client. Datastar's reconnect and morph behaviour is via's behaviour.
Pre-1.0. APIs change between minor versions; Migrating from v0.7 lists what v0.8 broke.
Offline-first apps. Actions and pushes need the server. A tab that loses its stream shows a banner and reloads when it can; it does not queue work.
Heavy client-side interaction. A canvas editor, a drag-heavy board or a game wants every frame in the browser, not a round trip. If that part is one widget in an otherwise server-driven page, make it an island; if it is the whole app, use a client framework.
Public static content. Docs, blogs and marketing pages served as files from a CDN need no process at all.
Large uploads and media. Action bodies are capped at 1 MiB (via.WithMaxBody) and native form uploads at 8 MiB (via.WithMaxUpload). Stream big files through your own handler.
Built-in auth. via ships origin checks and signed sessions, not login, users or roles.
Signal in the browser; State and List per tab on the server
Method values; checked by the compiler
templ + htmx
.templ files compiled to Go
htmx
Your handlers and store; no per-connection view state
URL strings in hx-* attributes; handlers routed by hand
Datastar + Go SDK
Any Go templating (html/template, templ, gomponents)
Datastar
Signals in the browser; the server holds what you give it
@post('/path') strings; handlers routed by hand
Go API + React SPA
JSX/TSX
React plus your app bundle
Client memory; the server holds data, not UI
JSON endpoints; typed across the boundary only with codegen (OpenAPI, protobuf)
Phoenix LiveView
HEEx templates in Elixir
phoenix_live_view.js
Assigns in one server process per connected view
phx-* event name strings matched in handle_event
Hotwire (Rails)
ERB with Turbo Frames and Streams
Turbo, plus Stimulus for JS
Server data per request; no per-connection view state
Routes and data-controller / data-action strings
via's client is Datastar, so the Datastar row shares its runtime and transport. What via adds is the Go layer: state scoped by field type, actions addressed by method, and a stream opened only where a unit needs one. The closest Go-native alternative is templ + htmx, which types the templates but leaves the state wiring to strings and handlers you route.
Build step: via needs none, and Datastar needs only templ's if you use templ. templ + htmx runs templ generate, React needs npm and a bundler, LiveView builds with Mix and esbuild, and Hotwire uses import maps or a bundler.
Server push: via and Datastar use SSE, via only on pages with a live unit. htmx adds SSE or WebSocket through extensions. LiveView runs over a WebSocket with a long-poll fallback and clusters through Phoenix.PubSub. Hotwire sends Turbo Streams over WebSocket (Action Cable) or SSE. A React SPA pushes with whatever you add.