gothic
One language. Server-rendered HTML. Surgical client-side DOM updates. No JavaScript tax.
The gap
HTMX made server-driven UI mainstream again. You render HTML on the server, swap fragments, and skip most of the SPA stack.
Then the interface needs a counter, a dropdown, a form that updates state without a round trip, or a progress indicator driven by a request. HTMX stops short. You reach for JavaScript, then Alpine.js, then a build chain, and suddenly the "one language" server app has a second language and a second toolchain.
gothic closes that gap. Client interactivity becomes part of the component language, compiled from the same source as your server render.
What gothic is
gothic is a fullstack Go framework inspired by templ and Bubble Tea. It compiles a Go application and a .gth view to two targets:
- Go that renders HTML on the server, and
- JavaScript that dispatches messages, runs
Update, and performs fine-grained DOM updates in the browser.
No virtual DOM. No hand-written client modules. No npm on the happy path.
// pingpong.gth, Go prelude
type Model struct{ Messages []string }
type PingClicked struct{}
type PongReceived struct{ Text string }
type PingFailed struct{ Message string }
type Procedures interface {
gothic.RPCService
Ping(ctx context.Context) (string, error)
}
func (m Model) Init() gothic.Cmd { return nil }
func (m Model) Update(msg gothic.Msg) (gothic.Model, gothic.Cmd) {
switch msg := msg.(type) {
case PingClicked:
m.Messages = append(m.Messages, "ping")
return m, callPing
case PongReceived:
m.Messages = append(m.Messages, msg.Text)
case PingFailed:
m.Messages = append(m.Messages, msg.Message)
}
return m, nil
}
func callPing() gothic.Msg {
pong, err := Ping()
if err != nil {
return PingFailed{Message: err.Error()}
}
return PongReceived{Text: pong}
}// pingpong.gth, view
view App(model Model) {
<div>
<button onclick={ PingClicked{} }>Ping</button>
<ul>
for _, m := range model.Messages {
<li>{ m }</li>
}
</ul>
</div>
}Application logic is ordinary Go built on Bubble Tea's Model-View-Update pattern. Events dispatch messages. Update returns the next model and an optional command. The view is a pure function of the model. Procedures are declared as Go interfaces that embed gothic.RPCService, so there is no rpc keyword. Clicking Ping appends ping immediately, runs the command, receives pong or an error message, and updates the DOM. There is no await, no JavaScript array syntax, and no separate client dialect.
The pipeline
pingpong.gth + main.go
|
gothic generate
|-- pingpong_gothic.go prelude + server render + View + procedure registrar
|-- pingpong_gothic.js compiled Init/Update/commands, render, dispatch
|
go build / go run
|-- serves HTML and assets
|-- embeds the JS runtime with go:embedOne binary. One build. The generated JavaScript ships from your Go server, embedded, with no separate deploy step.
Why no virtual DOM
Generated code creates static DOM once and binds only the dynamic parts. State changes patch the exact text node, attribute, or list that depends on them. There is no tree diff to read around, no reconciliation to debug. The output is small, readable, and close to the DOM you already know.
The client runtime stays tiny: text, list, branch, attribute, event, and RPC helpers.
How it fits together
- Go-first syntax. Loop headers, interpolations, and event expressions are parsed with Go's own parser. Embedded code reads like Go, not JavaScript.
- Shared AST, two walkers. The Go backend and the JavaScript backend consume the same parse tree, so server and client output cannot drift apart.
- Compiled DOM bindings. The compiler knows which expression feeds which text node, which condition owns which branch, and which key owns which list item. Updates patch those directly.
- Full hydration. The server emits markers. The client adopts the existing DOM instead of rebuilding it, so first paint is server HTML and interactivity attaches without a flash.
- Typed procedures. Server functions become generated Go registrars and typed client calls. Same-origin JSON by default, with a no-JS form fallback planned.
net/httpat the core. chi v5 is the blessed router;http.Handlercompatibility means no lock-in.
What already works
Every example runs end to end.
gothic generateemits Go and JavaScript.- The application is Bubble Tea-style MVU:
Model,Msg,Cmd,Init,Update, and a generatedView. - Messages are ordinary Go values. Commands are
func() Msg. - The Go server renders the initial model with hydration markers and serialized state.
- The client adopts the server list, hydrates list-item events, binds message-valued events, runs
Update, and patches text and lists. - Pure components compose views with typed props, in the app file or separate files.
- Pure components accept children through
gothic.Children; child events and state stay in the App. - Stateful components own their model, Init, Update, and view, and work in static positions, conditional branches, and keyed lists.
- Stateful components can nest other stateful components in static positions, branches, and keyed lists; child actions go straight to the App.
- Components unmount when their branch or list item leaves, so state resets on remount.
- Components use props structs, read props in
Update, react toPropsChanged, and support optional pointer fields. - Components seed their initial state from props through
PropsChanged{Initial: true}. Updateand commands support indexing, two-index slicing, map literals,len,delete, compound assignment,++/--, range loops, and non-type switch statements.gothic.Batchandgothic.Sequencecompose commands.- Path patterns and parameters work through the
routepackage on the server and in the browser. - The separate
virtualmodule keeps 12 rows in the DOM for a 5,000 row model usingvirtual.WindowForanddom.Pxspacers. - Components run their own commands and are destroyed on unmount.
- Components are destroyed on unmount, so late command results cannot update them.
- The separately versioned UI kit ships pure Button/Card/Field components and stateful Tabs/Accordion/Counter/Disclosure components.
- Components can emit action messages to the parent app; the app owns server procedures.
- Stateful components can take children through
gothic.Children, so containers keep state while the App owns the child content. - Gothicbook is a small design system demo for the UI kit, including form fields with App children, a server-backed action, and a scrollable, paginated list of expandable disclosures.
- Procedures accept JSON arguments, and keyed lists update in place.
- A curated
browsermodule covers alerts, logging, titles, localStorage, clipboard, focus, reload, and navigation. - Optional
sessionandauthmodules cover signed cookies and argon2id login; CSRF protection stays in core. LocationChangedandroutelinks provide path-based URL state with pushState, back, and forward.- The router example renders deep links on the server and navigates on the client without a reload.
- Parse and compiler errors show the file, line, source excerpt, caret, and a hint.
- Views support
if,else if, andelse; the server renders one branch and the client swaps it in place. - The SQLite TodoMVC example supports filters, conditional empty and error states, inline editing, mark-all, and clear-completed.
- Clicking Ping produces
ready,ping,pongwith no reload. - Failure handling produces
ready,ping,ping failed. - No hand-written JavaScript. No wasm. No npm.
- Unit, integration, and live browser tests pass.
gothic grows its own bootstrap compiler and may vendor small, attributed pieces of templ code when they save real work. It does not hard fork templ.
What comes next
- Named slots and fallback content.
- Wildcard or optional route segments and reverse URL building.
- Typed procedure argument validation and the no-JS form fallback.
go/packagestype information for stronger compiler checks.- Manifest, content hashing, formatter, LSP, and source maps.
Why this can work
- Bubble Tea proves Model-View-Update is idiomatic and production-ready in Go.
- templ proves server-rendered components with excellent tooling can win.
- Svelte, Solid, and Gea prove compile-time surgical DOM updates beat virtual DOM diffing for size and speed.
- Alpine and HTMX prove developers want reactivity without adopting a frontend framework.
go:embedand modern ES modules remove the need for a bundler in the default path.
gothic is the missing combination: Bubble Tea's state model, templ-style views, compiled DOM updates, and a single Go binary.
Status
Early, honest, and working. The pipeline is real, the demo runs, and the design decisions are documented. gothic grows its own compiler and may vendor small, attributed pieces of templ code when they save real work. It does not hard fork templ.
If you want a fullstack Go framework where the client is not a second codebase, this is that bet.