Introduction
tmplx is a framework for building full-stack web applications using only Go and HTML. Its goal is to make building web apps simple, intuitive, and fun again. It significantly reduces cognitive load by:
- keeping frontend and backend logic close together
- providing reactive UI updates driven by Go variables
- requiring zero new syntax
Developing with tmplx feels like writing a more intuitive version of Go templates where the UI magically becomes reactive.
You start by creating an HTML file. It can be a page or a reusable component, depending on where you place it.
You use the <script type="text/tmplx"> tag to embed Go code and make the page or component dynamic. tmplx uses a subset of Go syntax to provide reactive features like state, derived, and event handler. At the same time, because the script is valid Go, you can implement backend logic—such as database queries—directly in the template.
tmplx compiles the HTML templates and embedded Go code into Go functions that render the HTML on the server and generate HTTP handlers for interactive events. On each interaction, the current state is sent to the server, which computes updates and returns both new HTML and the updated state. The result is server-rendered pages with lightweight client-side swapping (similar to htmx). The interactivity plumbing is handled automatically by the tmplx compiler and runtime—you just implement the features.
Most modern web applications separate the frontend and backend into different languages and teams. tmplx eliminates this split by letting you build the entire interactive application in a single language—Go. With this approach, the mental effort needed to track how data flows from the source to the UI is reduced to a minimum. The fewer transformations you perform on your data, the fewer bugs you introduce.
Installing
tmplx requires Go 1.25 or later.
This adds tmplx to your Go bin directory (usually $GOPATH/bin or $HOME/go/bin). Make sure that directory is in your PATH.
After installation, verify it works:
Quick Start
Get a tmplx app running in minutes.
-
Create a project
-
Add your first page (pages/index.html)
-
Generate the Go code
-
Create main.go to serve the app
-
Run the server
That's it! Open http://localhost:8080 and you now have a working interactive counter.
Pages and Routing
A page is a standalone HTML file that has its own URL in your web app.
All pages are placed in the pages directory. Default pages location is ./pages. Change it with the -pages-dir flag:
tmplx uses filesystem-based routing. The route for a page is the relative path of the HTML file inside the pages directory, without the .html extension. For example:
pages/index.html→/pages/about.html→/about-
pages/admin/dashboard.html→/admin/dashboard
When the file is named index.html, it serves its directory's URL, which ends with a trailing slash. The page matches that URL exactly — it does not catch other paths under the directory — and a request without the trailing slash is redirected to it.
pages/docs/index.html→/docs/pages/index/index.html→/index/
A name and a directory are different URLs: login.html serves /login while login/index.html serves /login/, and both can exist in the same project. Two files that derive the same route cause compilation failure.
The exact match is what {$} does: in Go's net/http.ServeMux, a pattern ending in / matches the whole subtree, so a bare /docs/ pattern would serve the index page for every unmatched URL under it. tmplx therefore registers every index.html with {$} appended: pages/docs/index.html becomes the pattern GET /docs/{$}, which matches only /docs/ itself. That string is the page's identity, so you will meet it wherever the page is referred to — in the Routes() patterns when you attach middleware, and in the event POST URLs in the network tab:
-
pages/docs/index.html→ patternGET /docs/{$} -
pages/index.html→ patternGET /{$} -
pages/docs.html→ patternGET /docs(no{$}; a non-index route never ends in/)
To add URL parameters (path wildcards), use curly braces in directory or file names inside the pages directory. The name inside must be a valid Go identifier.
-
pages/user/{user_id}.html→/user/{user_id} -
pages/blog/{year}/{slug}.html→/blog/{year}/{slug}
These patterns are compatible with Go's net/http.ServeMux (Go 1.22+). The parameter values are available in page initialisation through tx:path comments.
A {name...} wildcard matches the rest of the URL, slashes included — a catch-all. It serves every path under its directory that no more specific page matches: an exact page wins first, then the directory's index.html, then the catch-all.
-
pages/docs/{rest...}.html→/docs/{rest...}
tmplx compiles all pages into a single Go file you can import into your Go project. The pages directory can be outside your project, but keeping it inside is recommended.
tmplx Script
<script type="text/tmplx"> is a special tag that you can add to your page or component to declare state, derived, event handlers, functions, and the special init() function to control your UI or add backend logic.
Each page or component file can have exactly one tmplx script. Multiple scripts cause a compilation error.
In pages, place it anywhere inside <head> or <body>.
In components, place it at the root level.
Imports
The script is plain Go, so you pull in packages with normal Go import declarations—single or grouped—at the top of the block.
Imports resolve against your project's go.mod. tmplx walks up from the working directory to the nearest go.mod (see CLI) and type-checks the script against that module, so you can import:
- the standard library;
- any third-party module already in your
go.mod(rungo getfirst); - your own packages within the module.
An import that does not resolve fails compilation with cannot import <path>. Imported struct or named types can be used as state. The playground resolves the standard library only.
Reserved Names
The compiler reserves two naming patterns for its own use:
- Identifiers (variables, function names, parameter names) declared in the tmplx script cannot start with
tx_. - HTML attributes starting with
tx-are reserved for tmplx directives (tx-if,tx-for,tx-on*,tx-action, ...). Do not introduce your owntx-attributes.
Expression Interpolation
Use curly braces {} to insert Go expressions into HTML. Expressions are allowed only in:
- text nodes
- attribute values
Placing expressions anywhere else causes a parsing error.
tmplx converts expression results to strings using
fmt.Sprint. The output is HTML-escaped in both
text nodes and attribute values to
prevent cross-site scripting (XSS)—an interpolated value cannot
inject markup or break out of its attribute.
Expressions run on the server every time the page loads or a component re-renders after an event. Avoid side effects in expressions, such as database queries or heavy computations, because they execute on every render.
Add the tx-ignore attribute to an element to disable
expression interpolation in that element's attributes and its direct
text children. Descendant elements are still processed normally.
State
State is the mutable data that describes a component's current condition.
Declaring state works like declaring variables in Go's package scope. If you provide no initial value, the state starts with the zero value for its type.
To set an initial value, use the = operator.
Although the syntax follows valid Go code, these rules apply:
- Only one identifier per declaration.
- The type must be JSON-compatible.
The 1st rule is enforced by the compiler. General JSON-compatibility is not checked at compile time (for now)—an interface or channel type compiles and then fails at runtime. The one exception is a function-typed state, which is rejected at compile time; use a callback prop for a function input.
Some invalid state declarations:
Some valid state declarations:
State can hold any JSON-compatible Go type. A few of them, live:
- int: 3
- string: tmplx
- bool: true
- slice: 2 items
The tmplx script cannot contain Go type declarations. To use your own struct or named types as state, declare them in a regular Go package and import them—then reference the imported type in the var declaration.
Derived
A derived is a read-only value that is automatically calculated from states. It updates whenever those states change.Declaring a derived works the same way as declaring package-level variables in Go. When the right-hand side of the declaration references existing state or other derived values, it is treated as a derived value.
Derived values follow most of the same rules as regular state variables, but with some differences:
- Only one identifier per declaration.
- Derived values cannot be modified directly in event handlers, though they may be read.
count = 1, doubled = 2
A derived can read any number of states—here a slice is joined into a class string:
Event Handlers
An event handler binds a DOM event to Go code that runs on the server. Add an attribute that starts with tx-on followed by the event name (tx-onclick, tx-oninput, tx-onchange, …); its value is a body of Go statements.
The body is plain Go—mutate state directly, call a function, or both. The simplest handler just assigns to a state variable; no function is required.
Any valid Go statement works in the body—here a compound assignment doubles a value on each click:
1
Event Properties
For events fired on a form control, you can read values directly from the DOM event object inside the handler body. The runtime injects them into the request automatically.
-
event.target.valueresolves to the target’s current string value. It is available on the value-bearing events:input,change,keydown,keyup,keypress,blur,focus,focusin,focusout, andsearch(i.e.tx-oninput,tx-onkeyup,tx-onblur, …). - On
keydownandkeypressthe value is the one before the keystroke is applied—usekeyuporinputfor the value after. - Reading
event.target.valueon any other event is a compile error.
You typed: (0 chars)
Functions
A function is a standalone, reusable Go function declared in the tmplx script as func name(...) { ... }. It is a separate idea from an event handler: a handler is a tx-on* binding, while a function is plain logic you can call from many places—an event handler, a form, init(), another function, or an expression.
Functions are not one-to-one with events. One function can back many bindings, and a binding may call no function at all (inline statements, as above) or several. Only tx-on* and tx-action bindings compile to HTTP endpoints—a function on its own does not.
Functions take parameters and may return values. Below, a single addNum backs ten buttons; each click passes a different argument:
0
A state-mutating function runs on the server through the handler or form that calls it. A function used inside an expression or derived value runs on every render, so keep functions in that position pure—no state mutation or write-side effects.
Captured Locals
Any local variable bound by an enclosing tx-for init clause, tx-for range form, or tx-if/ tx-else-if init form is automatically captured by handlers in the subtree. Just reference the local by name; the framework figures out which values cross the wire and decodes them with their inferred Go type.
- The captured local can appear anywhere in the handler body—a function argument, an array index, an expression operand. Whatever is valid Go.
- Types are recovered from the binding’s context (the surrounding state declarations and imports), so you don’t need to annotate.
- State, derived, and prop variables are not captured this way—the handler already reads them from the component’s saved state.
In the addn example above, i is captured from the tx-for init clause and decoded as int on the server. Captures from tx-if/tx-else-if init forms work the same way:
Both f (from tx-for) and n (from the tx-if init) are captured automatically.
init()
init() is a special function that runs automatically the first time a page or component is rendered. For pages, it runs on every GET request. For components, it runs when the component has no saved state yet (for example, the first time it appears on the page, or the first time a new tx-for iteration produces it). After that, subsequent renders reuse the saved state and skip init().
2026-07-14T09:34:27Z
Another common use case is to initialize one state from another state without turning the second variable into a derived state.
Path Parameters
When a page route contains a wildcard (see Pages and Routing), you can pull the captured value into a state variable by annotating the declaration with a //tx:path comment.
Rules:
- The comment must sit directly above the
varline (Go doc-comment position). - The value after
tx:pathis the wildcard name from the route pattern. - The variable must be declared as
string. No initial value is allowed—the captured string is the initial value. - Only pages support
tx:path; components cannot declare path-bound state.
The captured value is assigned before init() runs, so init() can use it to populate other state (for example, by loading a record from the database).
Single parameter. For a route pages/blog/post/{post_id}.html:
Multiple parameters. Each wildcard gets its own declaration. For a route pages/blog/{year}/{slug}.html:
After initialization, the variable behaves like any other state: it's serialized, sent to the server on events, and can be reassigned from handlers (though reassigning it does not change the URL).
Try it live: /hello/world · /hello/tmplx. This page binds the {name} URL segment with //tx:path name:
Control Flow
tmplx avoids new custom syntax for conditionals and loops. It embeds control flow directly into HTML attributes, similar to Vue.js and Alpine.js.
Conditionals
To conditionally render elements, use the tx-if, tx-else-if, and tx-else attributes on the desired tags. The values for tx-if and tx-else-if can be any valid Go expression that would fit in an if or else if statement. The tx-else attribute needs no value.
red
You can declare local variables and handle errors exactly as you would in regular Go code. Local variables declared in conditionals are available to the element and its descendants, just like in Go.
A conditional group consists of consecutive sibling nodes that share the same parent. Disconnected nodes are not treated as part of the same group. A standalone tx-else-if or tx-else without a preceding tx-if will cause a compilation error.
Loops
To repeat elements, use the tx-for attribute. Its value can be any valid Go for statement, including classic for or range for.
Local variables declared in the loop are available to the element and all of its descendants, just like in Go.
Always add a tx-key attribute with a unique value for each item. This gives the compiler a unique identifier for the node during updates.
To branch per item, do not put tx-if and tx-for on the same element—the condition is compiled outside the loop and cannot see the loop variable, which is a compile error. Put the conditional on an element inside the loop so it can read the bound variable:
- 1 (small)
- 2 (small)
- 3 (small)
- 4 (big)
- 5 (big)
- 6 (big)
<template>
The <template> tag is a non-rendering container that lets you apply control flow attributes (tx-if, tx-else-if, tx-else, or tx-for) to a group of elements at once.
The <template> itself is removed from the output; only its children are rendered (or not, depending on the control flow).
You can nest <template> tags and combine them with other control flow attributes on child elements.
Forms
Attach a handler to a <form> with tx-action. When the form is submitted, tmplx cancels the default submission, collects every named form element, and calls the handler on the server.
The value of tx-action must be the name of a function declared in the tmplx script. Each form element's name attribute must match a parameter name on that function; unnamed elements are ignored.
Values are JSON-decoded into each parameter's Go type, so the parameter type is what determines how the string is parsed. The runtime serializes form elements by input type:
-
text,email,password,textarea,select, etc.—sent as a JSON string. Decode intostring. -
number,range—sent as the raw numeric value, ornullwhen empty. Decode into a numeric type or pointer. -
checkbox—sent astrueorfalse. Decode intobool. -
radio—only the checked radio in a group is sent (using its sharedname). Decode intostring.
Because submission goes through a full server round-trip, use native HTML validation (required, minlength, pattern, ...) to catch client-side errors before the request is sent. For richer live-updating inputs, combine tmplx with a client-side library like Alpine.js.
Component
Components are reusable UI building blocks that encapsulate HTML, state, and behavior.
Create a component by placing an .html file in the components directory (default: ./components). tmplx automatically registers it as a custom element with the tag name tx- followed by the relative path (without the .html extension), with directory separators replaced by -.
Filenames and directory names may contain only a-z, 0-9, -, and _. Uppercase letters are rejected.
Examples:
-
components/button.html→<tx-button> -
components/user/card.html→<tx-user-card> -
components/todo/list.html→<tx-todo-list>
Components can contain their own <script type="text/tmplx"> for local state and logic, and can be used in pages or nested inside other components.
Props
Props are inputs the parent passes to a child component. Inside the child, a prop is declared like a state variable, but with a //tx:prop doc comment.
Rules:
- The
//tx:propcomment must sit directly above thevarline. - Prop names must be lowercase. HTML lowercases attribute names, so a camelCase prop name would never match the attribute the parent writes.
- An initial value (e.g.
= 0) becomes the default used when the parent omits the attribute. - Props are read-only inside the child. Event handlers can read them but cannot assign to them. Derived values referencing a prop recompute automatically when the prop changes.
- Pages cannot declare props—only components can.
Passing props
Prop attribute values on the parent are parsed as Go expressions, not as plain strings. Pass a literal by writing the literal directly; pass a parent variable by its name.
The expression is re-evaluated whenever the parent re-renders, so the child stays in sync with the parent's state automatically.
Clicks: 0
Doubled: 0
Callback Props
A callback prop lets a child notify the parent when something happens. It is just a prop whose type is a function: declare it with //tx:prop and a function type. With no default the parent must supply an implementation (a required prop); give it a function-literal default to make the parent override optional.
In the child, call it from a handler the same way you call a function:
In the parent, pass the bare name of a tmplx-script function as the attribute whose key matches the child's prop name:
When the child calls onselect(42), the parent's pick runs on the server with that argument and the parent re-renders. A callback call can be mixed freely with other statements in the same handler—for example tx-onclick="count++; onselect(42)".
To make the override optional, give the prop a function-literal default. The parent may then omit the attribute and the child falls back to its own implementation:
A runnable version: <tx-incbtn> takes a label and an onpress callback; the parent passes its own add function and counts the presses.
total: 0
<slot>
A <slot> marks a place in a component's template where the parent can inject content. Slots are how components stay composable: the child decides the shape, the parent fills in the details.
Declaring slots in a component
Each slot is either the default slot (no name) or a named slot. A component may have at most one default slot, and named slots must be unique. Slots cannot be nested inside other slots.
Content placed inside <slot>...</slot> is fallback content—it renders only when the parent does not fill that slot.
Filling slots from the parent
Put fill content directly inside the component tag. Use the slot attribute on a child element to target a named slot; everything else becomes the default fill.
Only the direct children of the component tag are considered when matching slots—a slot attribute on a deeply nested element has no effect.
Any markup here fills the <slot>.
Scope: fills use the parent's state
This is the most important rule. The content you pass into a slot is still parent code: expressions, event handlers, and directives inside a fill see the parent's state, derived, and prop variables—not the child's.
Here user and logout are defined on the page that uses <tx-card>, not inside the card component. When the button is clicked the page's handler runs and the fill re-renders against the page's updated state.
Live example
The docs site uses a simple <tx-example-wrapper> component with a single default slot to frame every live demo on this page. The component is just:
And callers wrap any demo with it:
CLI
Running tmplx inside any directory of your Go module walks up to the nearest go.mod and uses that as the project root. All path flags default relative to that root.
| Flag | Default | Description |
|---|---|---|
-pages-dir | ./pages | Directory containing pages. |
-components-dir | ./components | Directory containing reusable components. |
-output-file | ./routes.go | Path to the generated Go file. |
-package-name | main | Package name for the generated Go code. |
-handler-prefix | /tx/ | URL path prefix for generated event handler routes. |