via

You are reading the docs for v0.8. Read the latest (v0.10)

Why Via

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.

On this page

#The model in one example

why_quote.go
type WhyQuote struct {
	Qty   via.Signal[int] `via:"init=1"`
	Total via.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 {
	return h.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()),
	)
}
PartWhat it becomes
QtyA signal in the browser. The input edits it with no request; its value rides along on every action POST. See via.Signal.
TotalA 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.
PriceAn 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.
ViewThe HTML. It runs again after each action, and via patches the changed element into the page.

#Price a quote

The code above, running. The quantity is a browser value until you press Price; the total comes back as a patch on the stream.

0

Try this

  • Change the quantity: the Signals box follows it and no request is sent.
  • Press Price: one POST, then one patch carrying the <output>.
  • 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
package demos

import (
	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
)

type WhyQuote struct {
	Qty   via.Signal[int] `via:"init=1"`
	Total via.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 {
	return h.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()),
	)
}

#What you get

  • 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 one module requirement is testify, for its own tests.

#What it costs

  • 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.
  • 'unsafe-eval' in the CSP. Datastar compiles its expressions at run time, so the policy must allow eval; 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.

#When not to use via

  • 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.

#Compared with

Views inClient runtimeUI stateEvent wiring
via v0.8Go functions returning h.HDatastar, embedded in viaSignal in the browser; State and List per tab on the serverMethod values; checked by the compiler
templ + htmx.templ files compiled to GohtmxYour handlers and store; no per-connection view stateURL strings in hx-* attributes; handlers routed by hand
Datastar + Go SDKAny Go templating (html/template, templ, gomponents)DatastarSignals in the browser; the server holds what you give it@post('/path') strings; handlers routed by hand
Go API + React SPAJSX/TSXReact plus your app bundleClient memory; the server holds data, not UIJSON endpoints; typed across the boundary only with codegen (OpenAPI, protobuf)
Phoenix LiveViewHEEx templates in Elixirphoenix_live_view.jsAssigns in one server process per connected viewphx-* event name strings matched in handle_event
Hotwire (Rails)ERB with Turbo Frames and StreamsTurbo, plus Stimulus for JSServer data per request; no per-connection view stateRoutes 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.