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
  var item string = ""
  
  func add() {
    list = append(list, item)
    item = ""
  }
  
  func remove(i int) {
    list = append(list[0:i], list[i+1:]...)
  }
</script>

<label><input type="text" tx-value="item"></label>
<button tx-onclick="add()">Add</button>
<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.22 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 -out-file tmplx/routes.go
  4. Create main.go to serve the app

    package main
    
    import (
    	"log"
    	"net/http"
    
    	"hello-tmplx/tmplx"
    )
    
    func main() {
    	for _, route := range tmplx.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.

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

Default pages location: ./pages. Change it with the -pages flag:

$ tmplx -pages="/some/other/location"

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

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 add arguments from local variable declared within tx-if, or tx-for with the following rules:

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 once when a page is first rendered. Components cannot use this function.

2026-02-01T05:59:04Z

 <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

You can inject path parameters into states using a //tx:path comment placed directly above the state declaration. This feature works only in pages and requires the state to be of type string.

For example, given a route pattern like /blog/post/{post_id}, you can access the post_id parameter as follows:

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

The value of the post_id path parameter is automatically injected into the postId state during initialization. After that, postId behaves like any other state and can be read or modified as needed.

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

đźš§ Input Binding

This feature is in active development and may change or be removed in the future.

tmplx provides two-way binding (kind of) for <input> elements using the tx-value attribute. The input value is kept in sync with a state variable on the client side.

As the user types, the client-side state updates immediately. The displayed value only refreshes after a server round-trip that causes a re-render. Conversely, when another event handler modifies the state, the input value updates automatically on the next render.

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

  func update() {}
</script>

<input type="text" tx-value="name" placeholder="Enter your name" />
<button tx-onclick="update()">Greet</button>

<p tx-if='name != ""'>Hello, { name }</p>

Typing updates the client state instantly, but the greeting only changes after clicking the button, which triggers a server round-trip and re-render.

This is a current limitation of server-side re-renders. For smooth live preview, combine tmplx with a client-side library like Alpine.js to handle instant updates locally.

Component

docs in progress...

Syntax Highlight

Neovim Plugin