Architecture & internals
The core insight is a single sentence: SSR is a single synchronous one-pass evaluation. No re-render, no effects, setters are never called. So the server needs no React runtime, only the component's "render slice" — and its semantics are small enough to compile to Go.
The compile pipeline
1. Parse
2. Type-check
3. Reactivity analysis
4. Go backend
5. JS backend
6. go build
What hooks are in Go
The Go output of the same Counter.client.tsx
func Counter(props CounterProps) gotsx.Node {
return gotsx.Island("Counter", props, Counter_ssr(props))
}
func Counter_ssr(props CounterProps) gotsx.Node {
var n float64 = props.Start // useState → initial value
setN := func(any) {} // setter → empty function
var double float64 = (n * 2)
return gotsx.El("button", nil,
gotsx.Dyn(gotsx.Num(n)), gotsx.Text(" ×2 = "), gotsx.Dyn(gotsx.Num(double)),
gotsx.If((n > 4), func() gotsx.Node { return gotsx.El("b", nil, gotsx.Text(" 🔥")) }))
}useState(start)→var n float64 = props.Start; the setter is an empty function.useEffect, event handlers, async functions → not generated.- The reactive
{n}→gotsx.Dyn(with a marker); static text →gotsx.Text. - The island shell
gotsx.Island(name, props, inner)serializes props into an attribute and turns on marker mode.
Resumable hydration
The HTML the server emits (excerpt)
<gotsx-island name="Counter" props="{"start":0}">
<button>
<!--$-->0<!--/--> ×2 = <!--$-->0<!--/-->
<!--[--><!--]-->
</button>
</gotsx-island>The client receives JS of the same structure:
export default function Counter({ start }) {
const [n, setN] = G.signal(start);
const double = G.memo(() => (n() * 2));
G.effect(() => { console.log(n()); });
return G.el("button", { onClick: () => setN((n() + 1)) }, () => [
G.text(() => n()), G.t(" ×2 = "), G.text(() => double()),
G.cond(() => (n() > 4), () => G.el("b", null, () => [G.t(" 🔥")]))]);
}G.el claims the next element, G.t claims the next text node, G.text claims the text between <!--$-->…<!--/--> and binds an effect, G.cond / G.each claim the <!--[-->…<!--]--> block. Children are deferred with thunks until after the parent is claimed, so the order is source order. No diff, no rebuild, the existing DOM is reused as-is.
Host modules & the fence
- A host module is a Go value;
hostgenreflects it intohost.d.ts(for the editor) andhost.json(for the compiler, with Go names and numeric types). - What the dialect can do is exactly what Go exposes. Go is the single source of truth: routing, data, permissions, and actions all live in Go.
- The fence is at the type-check stage: a client importing host, a client importing a server component, await on the server, an unknown prop, member access on any — all compile errors.
SPA navigation
Click a link → fetch the new page's HTML → idiomorph morphs the body over → islands survive by DOM identity (same name and props stay put, changed props rebuild, gone ones unmount). Form GET is the same; back/forward go through popstate; concurrent navigations honor only the last; a non-HTML response falls back to a full page load.
Compared with the goja route
Same machine, same kind of page
| Item | goja + React + MUI | gotsx |
|---|---|---|
| list page render | 45–60 ms | ~30 µs |
| throughput | ~31 req/s (4 VMs) | ~28k req/s |
| client runtime | react-dom 62 KB gz | 6 KB |
| debugging | no breakpoints | delve / pprof / go test |
| npm ecosystem | pure-JS packages work | unavailable; libraries are written in the dialect |