Knowing JavaScript is usually a bare minimum requirement for web developers today. However, one Go package — the Fir toolkit — is making it possible to develop simple, reactive web applications without needing much JavaScript knowledge or experience with complex frameworks.
While Fir uses JavaScript under the hood, it’s hidden from developers. Instead, developers can use Fir’s own unique syntax to add reactivity to simple Go apps. Building more complex apps that need features like authentication flows, animations, or client-side validation might be challenging with Fir.
In this tutorial-style article, we’ll build a simple counter app that will help us understand the basics of Fir. Then, we’ll take it a step further by building a to-do app with Fir and its Alpine.js plugin to further explore its features. We will cover:
You can check out the complete code for our simple demo apps in this GitHub repository. Let’s jump in!
Before we start building, let’s first take a look at Fir and how it works.
Fir uses templates to render HTML on the server side and then send it back to the browser. At a high level, it works as illustrated below:
Fir offers an Alpine.js plugin that allows developers to write enhanced reactive web applications. With this plugin, it becomes possible to patch the DOM rather than rendering the template on the server and sending it down to the client:
When the user triggers an event — for example, by clicking on a button — the event is sent to the server via a WebSocket connection.
The server then executes the written Go code corresponding to that event and returns a rendered HTML template back to the client. Using Fir’s Alpine.js plugin, the DOM is patched at the appropriate place with the HTML returned by the server.
Just a note before we move ahead: Fir is still experimental and not production-ready. You should expect it to have breaking changes in upcoming releases, so keep this in mind before you use Fir in your project.
To follow this tutorial, you’ll need some familiarity with basic Go and HTML. Let’s get started with Fir in a new Go project by creating a folder as shown below:
mkdir go-fir-example cd go-fir-example
Then run the following command:
go mod init go-fir/example
This command will create a Go module with the given name.
Next, let’s install Fir so that we can use it in our code:
go get -u github.com/livefir/fir
With that done, we can start to play around with Fir.
Now that we have everything set up, let’s take the Fir library out for a spin. We’ll begin with a basic counter application to understand how the library works.
Start by creating a main
package in the main.go
file:
// ./main.go package main import ( "net/http" "sync/atomic" "github.com/livefir/fir" ) func index() fir.RouteOptions { var count int32 return fir.RouteOptions{ fir.ID("counter"), fir.Content("counter.html"), fir.OnLoad(func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.LoadInt32(&count)) }), fir.OnEvent("inc", func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.AddInt32(&count, 1)) }), fir.OnEvent("dec", func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.AddInt32(&count, -1)) }), } } func main() { controller := fir.NewController("counter_app", fir.DevelopmentMode(true)) http.Handle("/", controller.RouteFunc(index)) http.ListenAndServe(":9867", nil) }
In the code above, we initialize a Fir controller by giving it a name and some options. Here, we pass the development mode as true
to enable console logging. Then, using the http
package, we set up a basic HTTP server and listen for connections on port 9867
.
The more important part to focus on is the index
function passed to the Fir controller. The index
function returns a fir.RouteOptions
slice with several RouteOptions
. We pass in various options in the slice:
ID
Content
to be rendered when that route is hitOnLoad
event handlerOnEvent
handlersIn the OnLoad
handler, we load default values to a shared count
variable. Since the variable is shared, we use the sync/atomic
package to load and add values to the variable without any race conditions or overwriting.
We then use the RouteContext
to hydrate the HTML template. Notice that we pass count
as the first argument to the ctx.KV
function. We will see its significance in a moment.
Now let’s build a view for our app. To do that, create a counter.html
file and add the following to it:
<!-- ./counter.html --> <!DOCTYPE html> <html lang="en"> <body> {{ block "count" . }} <div>Count: {{ .count }}</div> {{ end }} <form method="post"> <button formaction="/?event=inc" type="submit">+</button> <button formaction="/?event=dec" type="submit">-</button> </form> </body> </html>
Two key things to note here are the usage of a template and the count
variable that we set earlier in OnEvent
handler in the HTML template.
We use the count
variable by surrounding it with curly braces {{ }}
. This syntax might remind you of the mustache syntax used in Vue.js. It helps Fir identify the dynamic parts of the template and render them using the variable value.
Another notable point is that clicking the buttons triggers a form action, sending an event
query parameter to the server. Depending on the value we send, Fir calls the appropriate onEvent
handler. The handler then changes the value of our shared variable, and Fir renders and sends the updated template.
The count
variable here is shared among multiple route calls. You can think of it like a global counter. If you have multiple windows open on the count page, updating one and refreshing the other will show the same count on both pages.
Here’s an overview of how the rendering process works:
The counter app works by triggering a form submission after the user clicks a button, which sends a request to the server. The server renders the template and sends it back to the browser, meaning the entire webpage is re-rendered every time the count is changed.
This approach is fine for a simple application. However, for a complicated application, it can cause serious performance issues in terms of UX and page re-renders. For example, if the page has many animations and elements, you will end up with an unresponsive and janky UI that also hampers UX and load times.
To fix this, we can use Fir’s Alpine.js plugin to avoid complete re-renders. We can load the plugin via the CDN. Let’s see how this works by building a simple to-do app with Go and Fir enhanced with Alpine.
To get started, let’s first make the necessary routing changes. We’ll move the counter to the /counter
route and add the to-do application to the root /
route:
// ./main.go package main import ( "fmt" "net/http" "sync/atomic" "github.com/livefir/fir" ) func index() fir.RouteOptions { var count int32 return fir.RouteOptions{ fir.ID("counter"), fir.Content("counter.html"), fir.OnLoad(func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.LoadInt32(&count)) }), fir.OnEvent("inc", func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.AddInt32(&count, 1)) }), fir.OnEvent("dec", func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.AddInt32(&count, -1)) }), } } func main() { if err != nil { panic(err) } controller := fir.NewController("fir_app", fir.DevelopmentMode(true)) http.Handle("/counter", controller.RouteFunc(index)) http.Handle("/", controller.RouteFunc(todo(db))) http.ListenAndServe(":9867", nil) }
Now let’s install two new packages:
uuid
, a Go package for generating unique UUIDsBoltDB is a very simple key-value database written in Go. We will use BoltHold on top of it to make querying and manipulating data easy:
package main import ( "fmt" "net/http" "sync/atomic" uuid "github.com/twinj/uuid" "github.com/livefir/fir" "github.com/timshannon/bolthold" ) func index() fir.RouteOptions { var count int32 return fir.RouteOptions{ fir.ID("counter"), fir.Content("counter.html"), fir.OnLoad(func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.LoadInt32(&count)) }), fir.OnEvent("inc", func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.AddInt32(&count, 1)) }), fir.OnEvent("dec", func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.AddInt32(&count, -1)) }), } } func main() { db, err := bolthold.Open("todos1.db", 0666, nil) if err != nil { panic(err) } controller := fir.NewController("fir_app", fir.DevelopmentMode(true)) http.Handle("/counter", controller.RouteFunc(index)) http.Handle("/", controller.RouteFunc(todo(db))) http.ListenAndServe(":9867", nil) }
Now let’s add the route handler:
package main import ( "fmt" "net/http" "sync/atomic" uuid "github.com/twinj/uuid" "github.com/livefir/fir" "github.com/timshannon/bolthold" ) func index() fir.RouteOptions { var count int32 return fir.RouteOptions{ fir.ID("counter"), fir.Content("counter.html"), fir.OnLoad(func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.LoadInt32(&count)) }), fir.OnEvent("inc", func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.AddInt32(&count, 1)) }), fir.OnEvent("dec", func(ctx fir.RouteContext) error { return ctx.KV("count", atomic.AddInt32(&count, -1)) }), } } type TodoItem struct { Id string `boltholdKey:"Id"` Text string `json:"todo"` Status string } type deleteParams struct { TodoID []string `json:"todoID"` } func todo(db *bolthold.Store) fir.RouteFunc { return func() fir.RouteOptions { return fir.RouteOptions{ fir.ID("todo"), fir.Content("todo.html"), fir.OnEvent("add-todo", func(ctx fir.RouteContext) error { todoItem := new(TodoItem) if err := ctx.Bind(todoItem); err != nil { return err } todoItem.Status = "not-complete" todoItem.Id = uuid.NewV4().String() if err := db.Insert(todoItem.Id, todoItem); err != nil { return err } return ctx.Data(todoItem) }), fir.OnEvent("delete-todo", func(ctx fir.RouteContext) error { req := new(deleteParams) if err := ctx.Bind(req); err != nil { return err } if err := db.Delete(req.TodoID[0], &TodoItem{}); err != nil { fmt.Println(err) return err } return nil }), fir.OnEvent("mark-complete", func(ctx fir.RouteContext) error { req := new(deleteParams) if err := ctx.Bind(req); err != nil { return err } var todoItem TodoItem if err := db.Get(req.TodoID[0], &todoItem); err != nil { return err } todoItem.Status = "completed" if err := db.Update(req.TodoID[0], &todoItem); err != nil { return err } return ctx.Data(todoItem) }), fir.OnLoad(func(ctx fir.RouteContext) error { var todos []TodoItem if err := db.Find(&todos, &bolthold.Query{}); err != nil { return err } return ctx.Data(map[string]any{"todos": todos}) }), } } } func main() { db, err := bolthold.Open("todos.db", 0666, nil) if err != nil { panic(err) } controller := fir.NewController("fir_app", fir.DevelopmentMode(true)) http.Handle("/counter", controller.RouteFunc(index)) http.Handle("/", controller.RouteFunc(todo(db))) http.ListenAndServe(":9867", nil) }
In the code above, we pass the fir.RouteOptions
returned by the todo
function to the Fir controller. This is on line 94 of our file.
The RouteOptions
are basically the same as in the counter app, but with some extra events and a different content file: todo.html
. This route handles three events:
add-todo
event creates a to-do item of type TodoItem
with data sent in the post
payload. It then inserts it into BoltDB and returns the item back to the clientdelete-todo
deletes the to-do item whose ID is sent in the post request payloadmark-complete
marks the to-do item as complete, updates the database with this new value, and sends the updated item to the clientWe use two important methods provided via the fir.RouteContext
to read and write values:
Bind
method extracts values from the POST payload. To do this, we define a type that mirrors the payload structure and pass it to the Bind
method. Using tags, we tell the Bind
method how to map values to the appropriate object propertiesData
method sets values, populates the corresponding template, and sends the rendered HTML back to the clientNow that we have covered the server portion of our app, let’s take a look at the HTML template and the bindings:
<!-- ./todo.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <script defer src="https://unpkg.com/@livefir/fir@latest/dist/fir.min.js" ></script> <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js" ></script> <title>TODO - App</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css" /> </head> <body> <div x-data> <div>TODO List</div> <div class="columns"> <form method="post" @submit.prevent="$fir.submit()" x-ref="addTodo" action="/?event=add-todo" @fir:add-todo:ok::todo="$refs.addTodo.reset()" > <div class="column"> <input placeholder="Todo item" class="input is-info" name="todo" type="text" /> </div> <div class="column"> <button class="button" type="submit">Add Item</button> </div> </form> </div> <div> <div class="columns center"> <div class="column"><b>Title</b></div> <div class="column"><b>Status</b></div> <div class="column"><b>Actions</b></div> </div> <div @fir:add-todo:ok::todo="$fir.appendEl()"> {{ range .todos }} {{ block "todo" . }} <div fir-key="{{ .Id }}" class="columns {{ .Status }}" @fir:delete-todo:ok="$fir.removeEl()" > <div class="column">{{ .Text }}</div> <div class="column" @fir:mark-complete:ok::mark-complete="$fir.replace()" > {{ block "mark-complete" . }} <div>{{ .Status }}</div> {{ end }} </div> <form method="post" @submit.prevent="$fir.submit()" class="columns column" > <input type="hidden" name="todoID" value="{{ .Id }}" /> <button class="column button is-danger" formaction="/?event=delete-todo" > Delete </button> <button class="column button is-primary" formaction="/?event=mark-complete" > Complete </button> </form> </div> {{ end }} {{end}} </div> </div> </div> </body> </html>
There is a lot to unpack here, so let’s break it down:
script
tags. The first one loads the plugin, and the second one loads Alpine.jsLet’s take a closer look at the first form:
<form method="post" @submit.prevent="$fir.submit()" x-ref="addTodo" action="/?event=add-todo" @fir:add-todo:ok::todo="$refs.addTodo.reset()" > <div class="column"> <input placeholder="Todo item" class="input is-info" name="todo" type="text" /> </div> <div class="column"> <button class="button" type="submit">Add Item</button> </div> </form>
Here, when the user clicks on the button, we prevent the default browser action and instead let the Fir plugin take care of the form submission. You can see where we set this up on line three.
Fir sends the event add-todo
to the server along with the payload. Instead of sending it via an HTML post request, Fir uses a WebSocket message that looks something like this:
The server responds with a hydrated HTML template:
Note that to look at the messages sent and received by the client, you can open the network tab in your browser’s dev tools and look for a request with the request code 101
. Click on it and open the Messages
tab.
After receiving this response, Fir identifies the target and follows the action tagged against that target. In this case, it appends the HTML to the element to which this target is attached, as shown on line one below:
<div @fir:add-todo:ok::todo="$fir.appendEl()"> {{ range .todos }} {{ block "todo" . }} <div fir-key="{{ .Id }}" class="columns {{ .Status }}" @fir:delete-todo:ok="$fir.removeEl()" > . . . . </div> {{ end }} {{ end }} </div>
Next, let’s look at the second form, which deletes or marks the to-do item as complete:
<form method="post" @submit.prevent="$fir.submit()" class="columns column" > <input type="hidden" name="todoID" value="{{ .Id }}" /> <button class="column button is-danger" formaction="/?event=delete-todo" > Delete </button> <button class="column button is-primary" formaction="/?event=mark-complete" > Complete </button> </form>
As before, when the user clicks the button, we prevent the default browser action and delegate it to the Fir plugin for handling.
To let the server know which to-do item to perform operations on, we create a hidden input field with the same value as the Id
field. When the user submits the form, this value is sent to the server.
For actions such as deleting an item or marking it as complete, Fir manipulates HTML content as declared in the HTML template below. For instance, when marking an item as complete, Fir replaces this element’s content:
<!-- when marking as complete --> <div class="column" @fir:mark-complete:ok::mark-complete="$fir.replace()" > {{ block "mark-complete" . }} <div>{{ .Status }}</div> {{ end }} </div>
Conversely, when deleting a to-do item, Fir removes this element:
<!-- when deleting a todo item --> <div fir-key="{{ .Id }}" class="columns {{ .Status }}" @fir:delete-todo:ok="$fir.removeEl()" > . . . . </div>
The key takeaway here is how Fir uses event binding to manipulate the browser DOM elements. For example, @fir:delete-todo:ok="$fir.removeEl()"
can be read as, “When the delete event is successful, remove this element.”
Similarly, the code below can be read as, “When the mark-complete event is successful, replace the content in this element”:
@fir:mark-complete:ok::mark-complete="$fir.replace()"
The final point to note regards the templating syntax. To render a list of things, we use the range
keyword. The block
keyword marks the start of the block, while the end
keyword signifies its end. To print values in a variable, we prefix the name of the variable with a .
dot.
That’s it! You can find the full code for our demo Fir and Go projects on GitHub.
Fir allows developers to write interactive web applications by using Go and HTML sprinkled with its own template syntax. This is a really cool tool for Go developers who want to develop simple web applications without dealing directly with JavaScript.
While it’s possible to develop simple applications or landing pages with Fir, it might be challenging to build more complicated projects due to its limited resources and incomplete documentation. If you do run into problems while using Fir, trial and error may be the best way to find a solution.
Fir is an open source project, so if you are interested in contributing you can head over to their GitHub repository. You can also open an issue there if you encounter any problems you can’t solve on your own.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowIt’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.