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:

  1. keeping frontend and backend logic close together
  2. providing reactive UI updates driven by Go variables
  3. requiring zero new syntax

Developing with tmplx feels like writing a more intuitive version of Go templates where the UI magically becomes reactive.

 <script type="text/tmplx">
  var list []string

  func add(item string) {
    list = append(list, item)
  }

  func remove(i int) {
    list = append(list[0:i], list[i+1:]...)
  }
</script>

<form tx-action="add">
  <label><input name="item" type="text" required></label>
  <button type="submit">Add</button>
</form>
<ol>
  <li
    tx-for="i, l := range list"
    tx-key="l"
    tx-onclick="remove(i)">
    { l }
  </li>
</ol>

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.24 or later.

$ go install github.com/gnituy18/tmplx@latest

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:

$ tmplx --help

Quick Start

Get a tmplx app running in minutes.

  1. Create a project

    $ mkdir hello-tmplx
    $ cd hello-tmplx
    $ go mod init hello-tmplx
    $ mkdir pages
  2. Add your first page (pages/index.html)

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Hello tmplx</title>
    </head>
    <body>
      <script type="text/tmplx">
        var count int
      </script>
    
      <h1>Counter</h1>
    
      <button tx-onclick="count--">-</button>
      <span>{ count }</span>
      <button tx-onclick="count++">+</button>
    </body>
    </html>
  3. Generate the Go code

    $ tmplx
  4. Create main.go to serve the app

    package main
    
    import (
    	"log"
    	"net/http"
    )
    
    func main() {
    	for _, route := range Routes() {
    		http.Handle(route.Pattern, route.Handler)
    	}
    
    	log.Fatal(http.ListenAndServe(":8080", nil))
    }
  5. Run the server

    $ go run .
    > Listening on :8080

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 -pages-dir="/some/other/location"

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:

When the file is named index.html, the index part is omitted from the route (it serves the directory path). To get a route like /index, place index.html in a subdirectory named index.

Multiple file paths can map to the same route. Choose the style you prefer. Duplicate routes cause compilation failure.

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.

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.

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 handler, 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>.

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
    <script type="text/tmplx">
      // Go code here
    </script>
    ...
  </head>
  <body>
    ...
  </body>
</html> 

In components, place it at the root level.

<script type="text/tmplx">
  // Go code here
</script>
...
...

Reserved Names

The compiler reserves two naming patterns for its own use:

Expression Interpolation

Use curly braces {} to insert Go expressions into HTML. Expressions are allowed only in:

Placing expressions anywhere else causes a parsing error.

tmplx converts expression results to strings using fmt.Sprint. The difference is that in text nodes the output is HTML-escaped to prevent cross-site scripting (XSS) attacks.

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.

<p class='{ strings.Join([]string{"c1", "c2"}, " ") }'>
 Hello, { user.GetNameById(0) }!
</p> 
<p class="c1 c2">
 Hello, tmplx!
</p>

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.

<p tx-ignore>
  { "ignored" }
  <span>{ "not" + " ignored" }</span>
</p> 
<p tx-ignore>
  { "ignored" }
  <span>not ignored</span>
</p>

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.

<script type="text/tmplx">
var name string
</script>

To set an initial value, use the = operator.

<script type="text/tmplx">
var name string = "tmplx"
</script>

Although the syntax follows valid Go code, these rules apply:

  1. Only one identifier per declaration.
  2. The type must be explicitly declared and JSON-compatible.

The 1st rule is enforced by the compiler. The 2nd is not checked at compile time (for now) and will cause a runtime error if violated.

Some invalid state declarations:

<script type="text/tmplx">
// ❌ Must explicitly declare the type
var str = ""

// ❌ Cannot use the := short declaration
num := 1

// ❌ Type must be JSON-marshalable/unmarshalable
var f func(int) = func(i int) { ... }
var w io.Writer

// ❌ Only one identifier per declaration
var a, b int = 10, 20
var a, b int = f()
</script>

Some valid state declarations:

<script type="text/tmplx">
// ✅ Zero value
var id int64

// ✅ With initial value
var address string = "..."

// ✅ Initialized with a function call (assuming the package is imported)
var username string = user.GetNameById("id")

// ✅ Complex JSON-compatible types
var m map[string]int = map[string]int{"key": 100}
</script>

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:

  1. Only one identifier per declaration.
  2. The type must be specified explicitly.
  3. Derived values cannot be modified directly in event handlers, though they may be read.
 <script type="text/tmplx">
  var num1 int = 100 // state
  var num2 int = num1 * 2 // derived
</script>

...
<p>{num1} * 2 = {num2}</p>
 <script type="text/tmplx">
  var classStrs []string = []string{"c1", "c2", "c3"} // state
  var class string = strings.Join(classStrs, " ") // derived
</script>

...
<p class="{class}"> ... </p>

Event Handler

Event handlers let you respond to frontend events with backend logic or update state to trigger UI changes.

To declare an event handler, define a Go function in the global scope of the <script type="text/tmplx"> block. Bind it to a DOM event by adding an attribute that starts with tx-on followed by the event name (e.g., tx-onclick).

<script type="text/tmplx">
  var counter int = 0

  func add1() {
    counter += 1
  }
</script>

<p>{ counter }</p>
<button tx-onclick="add1()">Add 1</button>

In this example, the add1 handler runs every time the button is clicked. The counter state increases by 1, and the paragraph updates automatically.

It’s not magic. tmplx compiles each event handler into an HTTP endpoint. The runtime JavaScript attaches a lightweight listener that sends the required state to the endpoint, receives the updated HTML fragment, merges the new state, and swaps the affected part of the DOM. It feels like direct backend access from the client, but it’s just a simple API call with targeted DOM swapping.

Arguments

You can pass arguments to handlers, but only from local variables declared inside tx-if, tx-else-if, or tx-for. State, derived, and prop variables cannot be passed as arguments—the handler already has access to them directly.

0

 <script type="text/tmplx">
  var counter int = 0

  func addNum(num int) {
    counter += num
  }
</script>

<p>{ counter }</p>
<button tx-for="i := 0; i < 10; i++" tx-key="i" tx-onclick="addNum(i)">
  +{ i }
</button>

Inline Statements

For simple actions, embed Go statements directly in tx-on* attributes to update state. This avoids defining separate handler functions.

1

 <script type="text/tmplx">
  var val int = 1
</script>

<p>{ val }</p>
<button tx-onclick="val *= 2">double it!</button> 

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-05-13T13:24:01Z

 <script type="text/tmplx">
  var t string

  func init() {
    t = fmt.Sprint(time.Now().Format(time.RFC3339))
  }
</script>

<p>{ t }</p>

Another common use case is to initialize one state from another state without turning the second variable into a derived state.

<script type="text/tmplx">
  var a int = 1
  var b int

  func init() {
    b = a * 2 // b remains a regular state
  }
</script>

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 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:

<!DOCTYPE html>
<html>
  <head>
    <script type="text/tmplx">
      // tx:path post_id
      var postId string

      var post Post

      func init() {
        post = db.GetPost(postId)
      }
    </script>
  </head>
  <body>
    <h1>{ post.Title }</h1>
  </body>
</html>

Multiple parameters. Each wildcard gets its own declaration. For a route pages/blog/{year}/{slug}.html:

<script type="text/tmplx">
  // tx:path year
  var year string

  // tx:path slug
  var slug string
</script>

<p>Viewing { slug } from { year }</p>

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).

Control Flow

tmplx avoids new custom syntax for conditionals and loops because that would increase compiler complexity. Instead, 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

 <script type="text/tmplx">
  var num int
</script>

<button tx-onclick="num++">change</button>
<div>
  <p tx-if="num % 3 == 0" style="background: red; color: white">red</p>
  <p tx-else-if="num % 3 == 1" style="background: blue; color: white">blue</p>
  <p tx-else style="background: green; color: white">green</p>
</div> 

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.

<p tx-if="user, err := user.GetUser(); err != nil">
  <span tx-if="err == ErrNotFound">User not found</span>
</p>
<p tx-else-if='user.Name == ""'>user.Name not set</p>
<p tx-else>Hi, { user.Name }</p>

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.

5
____ *
___ ***
__ *****
_ *******
*********
 <script type="text/tmplx">
  var counter int = 5
</script>

<div>
  <span> { counter } </span>
  <button tx-onclick="counter++">+</button>
</div>
<div tx-for="h := 0; h < counter; h++" tx-key="h">
  <span tx-for="s := 0; s < counter-h-1; s++" tx-key="s">_</span>
  <span tx-for="i := 0; i < h*2+1; i++" tx-key="i">*</span>
</div> 
<div tx-for="_, user := range users">
  { user.Id }: { user.Name }
</div>

<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.

 <script type="text/tmplx">
  var loggedIn bool = true
</script>

<template tx-if="loggedIn">
  <p>Welcome back!</p>
  <button tx-onclick="logout()">Logout</button>
</template>

<template tx-else>
  <p>Please sign in.</p>
  <button tx-onclick="login()">Login</button>
</template> 
 <script type="text/tmplx">
  var posts []Post = []Post{
    {Title: "First Post", Body: "Hello world"},
    {Title: "Second Post", Body: "tmplx is great"},
  }
</script>

<template tx-for="i, p := range posts" tx-key="i">
  <article>
    <h3>{ p.Title }</h3>
    <p>{ p.Body }</p>
    <hr>
  </article>
</template> 

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.

 <script type="text/tmplx">
  var greeting string

  func greet(name string) {
    greeting = "Hello, " + name
  }
</script>

<form tx-action="greet">
  <input name="name" type="text" required />
  <button type="submit">Greet</button>
</form>

<p tx-if='greeting != ""'>{ greeting }</p>

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:

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 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.

<script type="text/tmplx">
  //tx:prop
  var title string

  //tx:prop
  var count int = 0
</script>

<h3>{ title }</h3>
<span>{ count } items</span>

Rules:

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.

<!-- Go string literal (quotes are part of the expression) -->
<tx-card title='"Hello"' count="5"></tx-card>

<!-- A parent state/derived/prop variable by name -->
<tx-card title="heading" count="itemCount"></tx-card>

<!-- Any Go expression that matches the prop type -->
<tx-card title='strings.ToUpper(heading)' count="len(items)"></tx-card>

The expression is re-evaluated whenever the parent re-renders, so the child stays in sync with the parent's state automatically.

Callback Props

To let a child notify the parent when something happens, declare a function in the child with no body. The compiler then requires the parent to supply an implementation, just like a required prop.

In the child, use it like any other event handler:

<script type="text/tmplx">
  func onSelect(id int)
</script>

<button tx-onclick="onSelect(42)">Pick</button>

In the parent, pass the name of a tmplx-script function as an attribute whose key matches the child's function name:

<script type="text/tmplx">
  var selected int

  func pick(id int) {
    selected = id
  }
</script>

<tx-picker onSelect="pick"></tx-picker>

The attribute value is a bare function name, not a Go expression. When the child calls onSelect(42), the parent's pick runs on the server with that argument and the parent re-renders. If the child function has a body, the parent override is optional and defaults to the child's implementation.

<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.

<div class="card">
  <slot name="header">Default Header</slot>
  <div class="body">
    <slot>Default Body</slot>
  </div>
  <slot name="footer"></slot>
</div>

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.

<tx-card>
  <h2 slot="header">Custom Title</h2>
  <p>Custom content goes in the default slot.</p>
  <div slot="footer">Actions</div>
</tx-card>

Only the direct children of the component tag are considered when matching slots—a slot attribute on a deeply nested element has no effect.

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.

<script type="text/tmplx">
  var user string = "tmplx"

  func logout() {
    user = ""
  }
</script>

<tx-card>
  <h2 slot="header">Hello, { user }</h2>
  <button tx-onclick="logout()">Sign out</button>
</tx-card>

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:

<div class="example-frame">
  <slot></slot>
</div>

And callers wrap any demo with it:

<tx-example-wrapper>
  <tx-counter></tx-counter>
</tx-example-wrapper>

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.

Syntax Highlight

Neovim Plugin