
GoX lets you write HTML templates as typed Go expressions that compile to plain Go on the go.
.gox and .go files.templ compatible: gox.Elem implements Render(ctx, w) and drops in wherever a templ component is expected.Syntax guide: doors.dev/docs/template-syntax
For practical extensions on top of GoX, see
github.com/doors-dev/goxx.This README focuses on installation, workflow, editor integration, and the rendering API behind GoX.
Working with an LLM agent? Point it at
llms.md— the condensed agent-facing reference (syntax, workflow, pitfalls). See Using with LLM agents below for setup.
GoX is the template engine powering doors — a server-driven framework for building reactive web applications entirely in Go.
Doors lets you write full interactive UIs in Go without JavaScript, with reactive state, type-safe routing, and real-time sync under the hood. The browser acts as a remote renderer while your Go server is the UI runtime.
If you are looking for a complete framework on top of GoX, check out doors.
gox toolNot required, VS Code or Neovim extensions is enough to get started.
The easiest path is the prebuilt binary from GitHub Releases.
To install from source:
make install
That builds the bundled Rust formatter and installs gox. Building from source requires Go, Cargo, and a working native toolchain.
Recommended: use the official VS Code or Neovim extension.
The bare gox command starts the GoX language server. gox srv is the explicit form of the same command.
If you are wiring an editor manually:
gox or gox srv..go and .gox buffers.gopls client. GoX launches and proxies a gopls instance.gopls is on PATH, or pass -gopls /path/to/gopls.Useful server flags:
-gopls to point at a specific gopls binary-listen to expose the server over TCP or a Unix socket instead of stdio-listen.timeout to stop an idle socket server after a timeoutThe TCP listener is unauthenticated, so bind it to localhost or otherwise protect the port.
GoX sits in front of gopls. It parses .gox, generates .x.go, keeps source and target positions mapped both ways, and forwards normal Go language features through a gopls instance.
go get github.com/doors-dev/gox
Keep the installed gox tool and the Go module version reasonably in sync. Generated .x.go files carry a GoX version marker, and newer generated files require a tooling upgrade.
ElemMost of the time, rendering starts with Elem.Render(ctx, w).
func Badge(label string) gox.Elem {
return <span class="badge">~(label)</span>
}
func handleBadge(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := Badge("New").Render(r.Context(), w); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
Outside HTTP, you can render to any io.Writer:
func Example() error {
return Badge("New").Render(context.Background(), os.Stdout)
}
If you need the HTML as a string, render into a buffer:
func RenderBadge(label string) (string, error) {
var buf bytes.Buffer
err := Badge(label).Render(context.Background(), &buf)
return buf.String(), err
}
Use Elem.Print(ctx, printer) instead when you want to render through a custom Printer instead of the default HTML writer.
A typical package can contain all three kinds of files:
main.go # regular Go
page.gox # source template
page.x.go # generated Go
What you edit:
.gox.x.go as generated output.go files alongside bothWhat the tooling does:
.x.go on savegox gen does the same in batch mode.x.go files are removed automaticallyRules worth following:
.x.go manually.x.go suffix for hand-written filesgox # start the language server
gox gen # generate .x.go files for the current directory
gox gen ./pkg # generate a specific file or directory
gox fmt # format .gox and .go files in the current directory
gox fmt ./internal # format a specific directory
gox fmt ./main.go # format a specific file
gox ver # print the GoX version
By default, gox gen and gox fmt use the current directory and respect .gitignore. Both commands also accept an optional positional file or directory path. -no-ignore disables ignore handling, and -force skips target-file safety checks during generation.
The .gox parser produces a syntax tree. The assembler walks that tree and lowers template nodes into plain Go built around gox.Elem(func(cur gox.Cursor) error { ... }).
Alongside generation, GoX keeps a source-to-target translation map between .gox and .x.go. That mapping is used for the language server features.
gox fmt formats .gox with the bundled Rust formatter and regular .go files with gofmt. Embedded <script> and <style> blocks are reformatted too.
Generated .gox code ultimately works with four things:
Elem, Comp, and templ-compatible componentsCursor that builds structure and emits rendering operationsAttrs set attached to an open head before it is submitted and exposed via cursor methodsJob values consumed by a PrinterThat is the useful mental model for GoX. Once you understand those pieces, generated .x.go files are easy to read and the advanced hooks stop feeling magical.
These are the core renderable types:
type Comp interface {
Main() Elem
}
type Elem func(cur Cursor) error
Elem is the main render value in GoX. It is a function that renders through a Cursor.
It also:
Comp by returning itself from Main()Render(ctx, w)Print(ctx, printer)GoX also defines a minimal Templ interface for values that render with Render(ctx, w), and Cursor.Any knows how to emit those too.
So in normal Go code, a template is just a value you can return, store, pass around, and render.
CursorCursor is the low-level rendering state machine. It streams operations to a Printer and tracks active element heads so it can enforce correct ordering.
There are three head lifecycles:
Init(tag) -> optional Set / Modify -> Submit() -> child content -> Close()InitVoid(tag) -> optional Set / Modify -> Submit()InitContainer() -> child content -> Close()Regular and void heads become HTML tags. Containers do not emit a tag, but they still create open and close jobs in the stream, which makes them useful for grouping and for render-time transformations.
The important state rule is:
Submit(), you are still building a head and may mutate attributesSubmit(), you may emit child content, but you may no longer mutate that headcur.Context() returns the default context for jobs emitted through that cursor. cur.Printer() exposes the underlying printer for direct job emission. cur.Send() remains as a deprecated shortcut for cur.Printer().Send() and bypasses cursor state validation in the same way.
Generated .x.go files are mostly straightforward cursor code, similar to:
func Badge(label string) gox.Elem {
return gox.Elem(func(cur gox.Cursor) error {
if err := cur.Init("span"); err != nil {
return err
}
if err := cur.Set("class", "badge"); err != nil {
return err
}
if err := cur.Submit(); err != nil {
return err
}
if err := cur.Text(label); err != nil {
return err
}
return cur.Close()
})
}
Most placeholder rendering ends up calling Cursor.Any or Cursor.Many.
Cursor.Any understands:
string and []stringElem and []ElemComp and []CompJob and []JobEditorTempl[]interface{}Anything else falls back to escaped fmt.Fprint.
Attrs is the mutable attribute set attached to a head while that head is being built.
You mainly encounter attributes in three places:
SetModify to attach one or more render-time modifiersJobHeadOpen.AttrsAttrSet and AttrMod remain as deprecated compatibility aliases for Set
and Modify.
Important details from the API:
nil means "unset"false means "unset"The attribute system is also an extension point:
type Modify interface {
Modify(ctx context.Context, tag string, attrs Attrs) error
}
type Mutate interface {
Mutate(name string, value any) any
}
type Output interface {
Output(w io.Writer) error
}
Modify is head-level, not value-level. It runs right before a head is rendered, receives the full Attrs set, and can inspect or change the final attributes for that element. This is the hook used by render-time attribute transformations.
Mutate is value-level. It lets a new attribute value depend on the previous value already stored under the same name.
Output lets a value control its own escaped output.
That makes attributes more than plain HTML metadata. They are also part of the rendering pipeline.
Attribute values are entity-escaped, but URL schemes are intentionally not filtered: a user-controlled href or src can carry a javascript: or data: URL, unlike html/template. Validate untrusted URLs in application code, or implement scheme filtering in a custom Printer — that way the policy lives where you can exempt the places that legitimately need such URLs.
GoX exposes three main render-time extension points:
Editor for code that needs direct cursor accessProxy for wrapping or rebasing an element subtree before it rendersPrinter for consuming and transforming the emitted job streamEditorEditor is the escape hatch for render-time behavior that needs direct cursor access.
Use it when rendering needs to:
cur.Context()ProxyA Proxy wraps an Elem subtree before it renders.
You can do any type of transofrmation with proxy, for example:
A common implementation pattern is a proxy printer: call elem.Print(cur.Context(), customPrinter), inspect the first *gox.JobHeadOpen or *gox.JobComp, adjust it, then forward the rest into the current cursor.
PrinterRendering is a job stream.
Useful concrete job types include:
*gox.JobHeadOpen*gox.JobHeadClose*gox.JobText*gox.JobRaw*gox.JobBytes*gox.JobComp*gox.JobTempl*gox.JobFprint*gox.JobErrorImportant behavior from the API:
IDgox.NewPrinter checks j.Context().Err() before calling OutputCustom printers are where GoX opens up the most. They can buffer, transform, route, inspect, or reinterpret the stream instead of just writing HTML sequentially.
The helpers in helpers.go keep the API lightweight when you want one-off implementations:
gox.EditorComp for values that should be both Editor and Compgox.EditorCompFuncgox.EditorFuncgox.ProxyFuncgox.ModifyFuncgox.PrinterFuncgox.NewEscapedWritergox.NewEscapedWriter is useful when custom rendering code needs the same escaping rules as GoX text and attribute output.