DEV Community

Alex
Alex

Posted on

Go devs just got Superpowers

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
Enter fullscreen mode Exit fullscreen mode
  • Install the templ CLI tool.
  • Optional: install wgo for "live" reload

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
Enter fullscreen mode Exit fullscreen mode

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" 
Enter fullscreen mode Exit fullscreen mode

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:

  1. We include the framework’s assets; that’s crucial.
  2. Instead of just <link rel="stylesheet" href="...">, we used doors.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
Enter fullscreen mode Exit fullscreen mode

To use them, install the SQLite library:

 go get github.com/mattn/go-sqlite3
Enter fullscreen mode Exit fullscreen mode

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 */
}

Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add Source Beam to the location selector that holds combined country and city data.
  2. Derive separate Beams for country and city values.
  3. Transform our previous country selector into an abstract "place" selector.
  4. 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):

Main render:

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:

  1. Triggers subscribers only on value change. By default it uses ==to decide if an update is needed; you can supply a custom equality function.
  2. Synchronized with rendering. During a render pass, all participating nodes observe the same value.
  3. Propagates changes top-to-bottom. In other words, subscribers who are responsible for more significant parts of the DOM will be triggered first.
  4. Stale propagation is canceled. Cancels stale propagation if the value changes mid-propagation (override with NoSkip on Source Beam if needed).
  5. 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:

  1. Autofocus on the input.
  2. 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 enables await. Additionally, it compiles TypeScript if you provide type="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
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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) {
        /* ... */
}
Enter fullscreen mode Exit fullscreen mode

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 (and doors.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.

GitHub
Official Website

Special thanks to Adrian Hesketh for his brilliant work on templ, which made this project possible.

Top comments (4)

Collapse
 
hashbyt profile image
Hashbyt

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.

Collapse
 
derstruct profile image
Alex

Thanks. SaaS is where it shines.

Collapse
 
turboturtle profile image
TurboTurtle • Edited

Congrats to you! You did absolutely incredible work - it’s got the potential to be legendary 👏

Collapse
 
derstruct profile image
Alex

Thanks. I hope so.