HTMX proved there’s demand for different approaches. It’s simple and clever, but limited. Control logic ends up distributed between HTML attributes and back-end handlers. There’s no reactive state to support complex UI flows, and your application isn’t truly “live” - it still needs client-side code for updates without user interaction.
I took it to another dimension: centralized control in Go, flexible routing system, reactivity, and coordinated updates across the UI. The power of React, but without its confusion, npm baggage, and with some unique features.
To show what this looks like in practice, let’s build a dynamic, reactive dashboard app — written entirely in Go. Along the way, you’ll see how each core piece of the framework fits together: from live HTML updates and event handling to state management, concurrency control, and navigation.
Disclaimer.
- dev.to does not support
templ
highlighting, so I'll paste screenshots from vim (by the way), you can find the final code here- For demo purposes, I deliberately omitted error handling to reduce LOC; that's not how it should be done!
This is a deep dive that supplies you with enough knowledge to begin building yourself. If you just want the big picture, scroll through, check out the GIFs, and read the sections above them if something looks interesting.
Fun starts at 3. Location Selector.
0. Preparation
System
- Ensure your Go is at least
1.24.1
; you can check in the terminal by running:
go version
Command for wgo:
wgo -file=.go -file=.templ -xfile=_templ.go templ generate :: go run .
Project
Initiate project and add doors dependency
mkdir dashboard
cd dashboard
go mod init github.com/derstruct/doors-dashboard
go get github.com/doors-dev/doors
Optional, but recommended: SSL certs
Framework is optimized for HTTP/2/3. Without SSL, the browser limits the number of simultaneous requests to 6, which can cause issues in some rare, highly interactive and heavy-sync scenarios.
6 requests are not enough?
Each event goes via an individual HTTP request (it has benefits, e.g., native form data support). With some long-running processing and no concurrency control enabled, it's easy to hit the limit.
What about overhead?
The HTTP/2/3 multiplexing and header compression keep the cost of additional requests low; we are cool.
Cook self-signed SSL certs:
# install package
$ go install filippo.io/mkcert@latest
# makes generated certs trustable by the system (removes browser warning for you), optional
$ mkcert -install
# create certs in the current folder
$ mkcert localhost 127.0.0.1 ::1
...
The certificate is at "./localhost+2.pem" and the key at "./localhost+2-key.pem"
In a production environment behind a reverse proxy, there is no need for SSL on a Go app itself.
1. The First Page
General Page Template
./page_template.templ
This app has one page with multiple path variants, so a separate template isn’t needed.
Still, it's nice to have concerns separated.
Page Interface
The page must provide head
and body
content to the template:
Template
Two notes:
- We include the framework’s assets; that’s crucial.
- Instead of just
<link rel="stylesheet" href="...">
, we useddoors.ImportExternalStyle
, which also collects information for. CSP header generation. CSP is disabled by default, but this prepares us for it.
doors.Import...
handles local, embedded, and external CSS and JS assets. For JavaScript/TypeScript modules, it eenables build/bundle steps and generates an import map
Page
./page.templ
Page Path
In doors, the URI is decoded into a Path Model. It supports path variants, parameters, and query values.
Our path will have two variants:
-
/
location selector -
/:Id
dashboard for selected location
One parameter:
- Id of the city And two query values:
- forecast days
- units (metric/imperial)
We’ll omit query values for now and add them later.
Our path model:
- The framework uses
path
tags to match the request path against the provided pattern. - The matched variant’s field is set to true.
Page
The path structure is wrapped in the state primitive (Beam) and passed to the page render function::
To be compatible with the framework, the page type must implement Render(), return a component, and accept a Beam with the path model.
Page Handler
A function that runs when the path matches. It reads request data (doors.RPage) and chooses the response (doors.PageRouter).
In our case, it's straightforward:
doors.PageRouter
also supports soft (internal) and hard (HTTP) redirects and serving static pages.
Router
./main.go
Create a doors router, provide a page handler, and launch the server.
Notice how doors.Router
just plugs into the Go standard server! Go is awesome.
We are live!
2. Database
To search countries and cities, I used SQLite databases from here
./sqlite
├── cities.sqlite3
└── countries.sqlite3
To use them, install the SQLite library:
go get github.com/mattn/go-sqlite3
DB wrappers
The framework operates in a server environment, so it can query the database directly.
Some code is omitted in these snippets; see here for the full source.
Countries Database
To search and get countries
./driver/countries.go
type CountriesDb struct {
db *sql.DB
}
// get country by id
func (d *CountriesDb) Get(id int) (Place, error) {
/* some sql */
}
// search country
func (d *CountriesDb) Search(term string) ([]Place, error) {
/* some sql */
}
Cities Database
To search and get cities.
./driver/cities.go
type CitiesDb struct {
db *sql.DB
}
type City struct {
Name string
Country Place
Id int
Lat float64
Long float64
}
func (d *CitiesDb) Get(city int) (City, error) {
/* some sql */
}
func (d *CitiesDb) Search(country int, term string) ([]Place, error) {
/* some sql */
}
Initialization
Initialize database wrappers for external use.
./driver/driver.go
package driver
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
"log"
)
var Countries *CountriesDb
var Cities *CitiesDb
func init() {
countries, err := sql.Open("sqlite3", "./sqlite/countries.sqlite3")
if err != nil {
log.Fatal("Failed to open database:", err)
}
Countries = &CountriesDb{db: countries}
cities, err := sql.Open("sqlite3", "./sqlite/cities.sqlite3")
if err != nil {
log.Fatal("Failed to open database:", err)
}
Cities = &CitiesDb{db: cities}
}
// minimal location info
type Place struct {
Id int
Name string
}
3. Location Selector
./location_selector.templ
Search Input
Write a search fragment and render it on the page.
Fragment
A fragment is a struct with a Render() templ.Component method
Input Component
Attach an event hook to the input field:
doors.AInput
creates a temporary, private endpoint for this element and event.
Options Component
Queries and renders search results.
Each
doors.Door
renders in parallel on a goroutine pool, so data queries during render generally don’t slow page rendering.
Put it on the page
./page.templ
...and enjoy!
Take a second to realize. We just implemented dynamic search without custom JS or API and within a single control flow.
Debounce and Loader
You don't want to stream every keystroke to the server.
Add one line to the input configuration to enable debounce filtering with the Scopes API:
With debounce, repeated values are more likely. Updating search results with the same data in this rare scenario makes me unhappy. Add a simple check to prevent it:
doors guarantees that the same hook’s invocations run in series, so
prevValue
has no concurrent access issues.
In practice, responses aren’t instant, so indicate progress to the user.
PicoCSS provides an attribute for this. Use the Indication API to toggle it during pending operations.
Final code:
Debounce and indication together (simulated latency):
The indication clears after all hook-triggered changes apply on the client.
Reactive State
Country and city selection can use Door only, without any reactive state.
However, in multi-step forms and complex UIs, this "low-level" approach spreads logic across handlers and hurts readability and debuggability. A single source of truth in that case significantly reduces mental overhead.
Country Selection
Add a Source Beam and subscribe to it in the render function:
Country selector component (previously inside the main render function):
Show the selected country and a reset button:
Update the beam on option click:
BlockingScope
cancels all new events while the previous one is being processed. It reduces unnecessary requests and clarifies intent.
Also, note that we used the same scope set for all search options, which effectively means that events from all handlers pass through a single pipeline, allowing only one active handler.
Let's see how selection works with reactive state:
Oops. Search results weren’t cleared. That makes sense; we didn't clear them.
Fix:
Result:
Location Selector
The country selector is a prototype for an abstract place selector. Comment it out for now.
Plan:
- Add Source Beam to the location selector that holds combined country and city data.
- Derive separate Beams for country and city values.
- Transform our previous country selector into an abstract "place" selector.
- Write the location selector render function with the place selectors.
Let's Go!
1. Create a Source Beam that holds country and city data.
2. Derive the country and city Beams.
Source Beam is the original value, which can be updated, mutated, and observed. Beam can only be observed.
3. Abstract the place selector.
Structure:
Methods from our previous country selector with minimal changes (see comments):
Show selected:
Select place:
Input:
And options:
4. Use our place selectors in the location selector render.
Dynamic form with reactive state:
Beam is a communication primitive with a value stream. You can watch it directly or use doors.Sub
/doors.Inject
to render a Door that updates automatically on change.
It has a few important properties:
-
Triggers subscribers only on value change. By default it uses
==
to decide if an update is needed; you can supply a custom equality function. - Synchronized with rendering. During a render pass, all participating nodes observe the same value.
- Propagates changes top-to-bottom. In other words, subscribers who are responsible for more significant parts of the DOM will be triggered first.
-
Stale propagation is canceled. Cancels stale propagation if the value changes mid-propagation (override with
NoSkip
on Source Beam if needed). - Derived beams update as a group. Subscription handlers run in parallel on a goroutine pool.
All these properties together just make it work as expected - you rarely need to think about it.
Bonus: Improve the UX (with JavaScript)
Missing keyboard support in a form is annoying.
Add keyboard support:
- Autofocus on the input.
- Tab and enter support on options.
Wiring up some JS
Focus by Id via JS:
Better: make it a reusable component:
Sleek.
And render after the input:
doors.Script
is awesome. It converts inline script into a minified (if not configured otherwise), cacheable script with src, protects global scope, and enablesawait
. Additionally, it compiles TypeScript if you providetype="application/typescript"
attribute.
Enabling Tab + Enter
Attach a key event hook and add tabindex
to options:
Keyboard control enabled:
4. Page
Apply Selected Location.
Here’s the page code again:
It always displays the location selector. Remember, we have two path variants: /
and /:Id
. The second is used when a location is selected.
And now take a look at this fella:
You can probably guess where this goes (path subscription). We’ll also have query parameters, but we don’t want the whole page to rerender on each query change, so derive:
And then subscribe:
Display the selected city or 404:
Now the location selector must change the path dynamically.
Add an apply dependency to the location selector:
Implement submit functionality:
Provide the apply function to the selector:
Instead of passing a path mutation function, we could just render a link. This example shows how to navigate programmatically.
Location selected via path mutation:
Bonus: Advanced scope usage.
In practice, the submit handler won’t respond instantly. What if we interact with the UI during processing?
Let's simulate conflicting actions:
It didn't change the outcome, but caused weird UI behavior.
This issue can be easily mitigated with Concurrent Scope from the Scopes API.
Concurrent Scope can be “occupied” only by events with the same group id.
Add a concurrent scope to the location selector:
Add a parent scope property to the place selector:
Assign group Id 1 to both place selectors:
Use it on the ‘change place’ button:
Finally, apply it to the submit button with a different group Id:
This setup ensures that either the submit or the change-place event can run, not both at the same time:
While changes to city and country won't affect each other, since they share the same group:
Concurrency control is needed because of the framework’s non-blocking event model. This is a major advantage of doors over Phoenix LiveView or Blazor Server, enabling highly interactive UIs without UX compromises.
Dynamic Title.
In doors you can register a JS handler on the front end with $d.on(...)
and invoke it from Go with doors.Call(...)
. Implementing a dynamic title is straightforward.
However, you can just use premade doors.Head
component:
Besides
title
, it also supports<meta>
tags.
5. Dashboard
Preparation
Weather API
Connect a weather API to retrieve time-series data for the dashboard charts:
./driver/weather.go
package driver
import (
"context"
"encoding/json"
"net/http"
"time"
"fmt"
)
type WeatherAPI struct {
endpoint string
timeout time.Duration
}
// time-series data types
type FloatSamples struct {
Labels []string // date-time
Values []float64 // measurement
}
type StringSamples struct {
Labels []string // date-time
Values []string // string parameter
}
// get the humidity forecast for a specified city for the given number of days
func (w *WeatherAPI) Humidity(ctx context.Context, city City, days int) (FloatSamples, error) {
/* HTTP request and parsing */
}
// get the temperature forecast for a specified city for the given number of days in the chosen units (metric/imperial)
func (w *WeatherAPI) Temperature(ctx context.Context, city City, units Units, days int) (FloatSamples, error) {
/* HTTP request and parsing */
}
// get the wind-speed forecast for a specified city for the given number of days in the chosen units (metric/imperial)
func (w *WeatherAPI) WindSpeed(ctx context.Context, city City, units Units, days int) (FloatSamples, error) {
/* HTTP request and parsing */
}
// get the weather code (e.g., cloudy/rainy) forecast for a specified city for the given number of days
func (w *WeatherAPI) Code(ctx context.Context, city City, days int) (StringSamples, error) {
/* HTTP request and parsing */
}
type Units int
const (
Metric Units = iota
Imperial
noUnits
)
func (u Units) Label() string {
/* ... */
}
func (u Units) WindSpeed() string {
/* ... */
}
func (u Units) Temperature() string {
/* ... */
}
func (u Units) Humidity() string {
return "%"
}
func (u Units) Ref() *Units {
return &u
}
The open-meteo API can return all parameters in a single request; we make separate requests to keep each chart independent for educational purposes.
Initialize it:
./driver/driver.go
/* ... */
var Weather *WeatherAPI
func init() {
/* ... */
Weather = &WeatherAPI{
endpoint: "https://api.open-meteo.com/v1/forecast",
timeout: 10 * time.Second,
}
}
Charts generation
I used github.com/vicanso/go-charts to generate SVG based on the weather data.
./driver/charts.go
// generate line-chart SVG
func ChartLine(values []float64, labels []string, unit string) ([]byte, error) {
/* ... */
}
// count value occurrences and generate a pie-chart SVG
func ChartPie(values []string) ([]byte, error) {
/* ... */
}
github.com/vicanso/go-charts is good enough for demos, but it’s buggy and no longer maintained.
Query Params
In the weather API, besides the city, we also have two variables: units (metric/imperial) and forecast days.
Add it to our path model:
./page.templ
Notice I used reference types. Otherwise, query parameters get a zero value and always appear in the URI.
Dashboard fragment and navigation.
./dashboard.templ
Separate the dashboard fragment.
To keep the page simple, let's move the dashboard to a separate fragment.
The dashboard depends on the location ID (provided by the page already) and the days and units query parameters:
We derive those from the path:
Render the dashboard on the page:
Menu (dynamic links)
Change City
For the location selector to appear, we need to render a link to /
. It would also be nice if query parameters persisted, so we generate the link based on the settings beam:
AHref
also supports the Scopes and Indication APIs
Switch to the location selector via dynamic link:
After clicking, the query parameters appear with default values. It’s okay, but not ideal.
Provide nil for the defaults so the behavior is consistent:
Now I’m happy:
Units
Render a link to switch units back and forth:
Add some styles and render the units switcher:
Query param switching:
Forecast Days.
The forecast-days links must preserve the units query value. To avoid unnecessary updates, derive a beam for the units:
Subscribe the menu to it:
And maintain the units query value:
Reactive menu:
doors doesn’t keep the whole DOM in memory. With beam derivation, you explicitly tie a specific HTML section to a specific piece of data. Diff data, not DOM.
Bonus: Active link highlighting
The client can automatically apply active-link highlighting if you configure it in doors.AHref
:
By default, it checks the whole path and all query values to apply the indication, but you can configure a narrower matching strategy.
Active link is highlighted:
Path as state is powerful. It’s declarative, type-safe, and doesn’t constrain how you map paths to HTML.
Charts
Temperature
Let’s prepare a temperature chart and see how it goes.
Instead of doors.Sub
, I’ll use the doors.Inject
helper. It essentially does the same, but instead of evaluating a function, it renders children with context that contains the beam value.
To serve the generated SVG, I’ll use doors.ARawSrc
, which creates a src
attribute with a custom request handler:”
doors.ARawSrc
(anddoors.ASrc
,doors.AFileHref
,doors.ARawFileHref
) use hook mechanics and privately serve resources.
Temperature line chart with dynamic SVG:
Everything
Abstract the chart component so it can be reused for all charts:
All charts:
UX improvements
Image preloader + parameter-switch indication:
Include this indication on all menu links:
Charts with preloaders:
Final Optimization
You may have noticed that weather and humidity don’t depend on the units
value.
Apprach as always - derive the beam that does not depend on units:
Additionally, we don’t need that indication triggered, so make it more specific:
Chart component with a days
variation:
Final result (slow internet simulation):
Page size:
where ~13 KB is PicoCSS and ~10 KB is the doors client.
Conclusion
Confession. I hate writing UIs, it always gives me the impression I’m doing something wrong. It's like having 10 different tools, none of which are designed for the job, so you have to combine them in an awkward way to get something done. It doesn’t feel like programming anymore.
With doors it feels like programming. Predictable solution path, known outcome, and freedom.
I enjoyed every minute of writing this small app, and I hope you will find time to experience it yourself.
Special thanks to Adrian Hesketh for his brilliant work on templ, which made this project possible.
Top comments (4)
This framework exemplifies how reactivity and state management can be elegantly handled at the server level without sacrificing user experience. It challenges conventional frontend paradigms and points to exciting possibilities for building more maintainable and performant SaaS interfaces.
Thanks. SaaS is where it shines.
Congrats to you! You did absolutely incredible work - it’s got the potential to be legendary 👏
Thanks. I hope so.