Via

Reactive web apps in pure Go

A composition is a struct. Reactive state is a typed field. Actions are methods. The compiler understands your UI.

Get started Why Via? View on GitHub


A complete Via app — a Local counter that’s independent in every tab and a Shared counter that syncs across every session. No template files, no build step, no hand-written JavaScript:

type Page struct {
    Local  via.StateTabNum[int] // per-tab — independent in every tab
    Shared via.StateAppNum[int] // shared across every session
}

func (p *Page) IncLocal(ctx *via.Ctx)  { p.Local.Op(ctx).Inc() }
func (p *Page) IncShared(ctx *via.Ctx) { p.Shared.Op(ctx).Inc() }

func (p *Page) View(ctx *via.CtxR) h.H {
    return h.Div(
        h.P(h.Text("Local: "), p.Local.Text(ctx)),
        h.Button(h.Text("+1"), on.Click(p.IncLocal)),
        h.P(h.Text("Shared: "), p.Shared.Text(ctx)),
        h.Button(h.Text("+1"), on.Click(p.IncShared)),
    )
}

Build and run it →

What makes Via different

Via is the only framework — in any language — that expresses the client/server reactive split as a Go type. Signal[T] is a client signal, mirrored into the browser by Datastar — the runtime Via uses to keep the page reactive and update it in place. StateTab[T], StateSess[T], StateApp[T] are server-only. Whether a piece of UI state round-trips or doesn’t is a choice made at the field declaration, checked by the compiler, not by a convention you can grep for. Transport is SSE only — one stream per tab — so there are no WebSockets to wrestle with a corporate proxy.

Two browsers, two scopes — StateTabNum[int] is per-tab, StateAppNum[int]
is shared across every session.

The thesis: the client/server split is a Go type

Every server-rendered framework eventually faces the question “is this state client-owned or server-owned?” In every other ecosystem the answer is a convention. In Via it is the field’s type.

Declare client-owned state as Signal[T]. Declare server-owned state as StateTab[T], StateSess[T], or StateApp[T]. The compiler enforces which side owns what. View helpers, actions, and lifecycle hooks all see the correct shape.

type Page struct {
    // Client-owned. Lives in the browser, driven by Datastar.
    // Bind to <input>; mutate without a round-trip.
    Theme via.Signal[string] `via:"theme,init=auto"`

    // Server-owned. Lives only in Go. Re-renders re-emit the value.
    Hits  via.StateTab[int]
}

Theme mutates inside the browser — flipping it from an <input> does not POST. Hits mutates only through an action handler; the next flush diffs the View and ships targeted DOM patches over SSE. The four reactive shapes are covered in Reactive state.

How reactivity runs

   ┌──────────────────────────┐                       ┌──────────────────────────┐
   │  Browser                 │  ◀──── SSE patches ── │  Server (Go)             │
   │                          │     + signal deltas   │                          │
   │  Datastar runtime        │                       │  Compositions            │
   │   Signal[T] nodes        │                       │   StateTab[T]            │
   │   data-* subscriptions   │                       │   StateSess[T]           │
   │                          │                       │   StateApp[T]            │
   │                          │  ────── POST ──────▶  │   per-tab action mutex   │
   │                          │       actions         │                          │
   └──────────────────────────┘                       └──────────────────────────┘
        view reactivity                                  truth + side effects

Two reactive runtimes, one typed boundary. Go owns truth; the client owns view reactivity. UI state the client owns (modal open, current tab, filter string) reacts instantly with zero SSE traffic; state the server owns (DB rows, cross-tab invariants, secrets) flows through actions and re-renders.

Scale across pods

The Shared counter above is per-pod by default. Wire in a backplane and it converges across every instance — and survives a restart — with the same typed fields. One line at boot:

app := via.New(via.WithBackplane(via.InMemory())) // dev: no infra
// prod: via.WithBackplane(bp) where bp, _ := vianats.JetStream(nc) — durable, clustered

StateApp/StateSess cluster with no API change; a new opt-in StateAppEvents[E, V] carries high-churn shared state — a counter, a chat feed, a queue — as an append-only event log that every pod folds to the same value. In preview on the way to 1.0; see Distributed state.

Where to go next