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

A hand-written parser for the TSX subset (lexer + recursive descent + JSX mode), about 900 lines of Go.

2. Type-check

Local inference + boundary annotations: props, host signatures, hooks, the builtin method table. Stepping outside the subset errors.

3. Reactivity analysis

Which expressions read a signal/memo. This single analysis drives both backends, so the hydration markers are guaranteed to line up.

4. Go backend

Component → Go function, JSX → gotsx.El/Text/If/Nodes, hooks → single-pass semantics, host:* → direct call. gofmt validates the syntax.

5. JS backend

Component → function, useState → G.signal, a signal-dependent const → G.memo, JSX → G.el/t/text/cond/each.

6. go build

The generated Go compiles together with your main.go and host package into a single binary; the client JS is served directly as ES modules.

What hooks are in Go

The Go output of the same Counter.client.tsx

gen/islands_Counter_client_gen.go
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)

the markers inside an island
<gotsx-island name="Counter" props="{&quot;start&quot;:0}">
  <button>
    <!--$-->0<!--/--> ×2 = <!--$-->0<!--/-->
    <!--[--><!--]-->
  </button>
</gotsx-island>

The client receives JS of the same structure:

gen/client/Counter.js
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.

Why it can be this simple: both sides' structure comes from the same compiler and the same reactivity analysis. React needs a diff because it doesn't know what the server actually rendered.

Host modules & the fence

  • A host module is a Go value; hostgen reflects it into host.d.ts (for the editor) and host.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

Itemgoja + React + MUIgotsx
list page render45–60 ms~30 µs
throughput~31 req/s (4 VMs)~28k req/s
client runtimereact-dom 62 KB gz6 KB
debuggingno breakpointsdelve / pprof / go test
npm ecosystempure-JS packages workunavailable; libraries are written in the dialect