Welcome welcome!
Welcome back again to my adventure series on the NATS JetStream Key/Value Store!
Welcome to Part 8 of our series on the NATS JetStream KV Store! We're finally hitting some real development. We're going to cover how Datastar works a bit and use it to hit our endpoints and display some UI!
Where did we leave off?
We had just created multiple boring pages and styled them in our application. Now let's add more!
Routes routes routes
First, let's create our other routes. We will have 3 pages, an Index page at the root, a Dashboard page for game lobbies and then a page for unique Games.
Let's create them!
$ touch routes/dashboard.go routes/game.go
$ touch web/pages/dashboard.templ web/pages/game.templ
After generating the necessary files, the next step is to define the routes and corresponding pages.
Routes
Dashboard.go
package routes
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/rphumulock/ds_nats_ttt/web/pages"
)
func setupDashboardRoute(router chi.Router) error {
router.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) {
pages.Dashboard().Render(r.Context(), w)
})
return nil
}
This follows the same routing approach we implemented earlier.
Game.go
package routes
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/rphumulock/ds_nats_ttt/web/pages"
)
func setupGameRoute(router chi.Router) error {
router.Get("/game/{id}", func(w http.ResponseWriter, r *http.Request) {
pages.Game().Render(r.Context(), w)
})
return nil
}
Similar to the previous route, but with the addition of a parameter in the endpoint URL. This will hold our unique game ids.
Router.go
func SetupRoutes(ctx context.Context, logger *slog.Logger, router chi.Router) (cleanup func() error, err error) {
cleanup = func() error {
return errors.Join()
}
if err := errors.Join(
setupIndexRoute(router),
setupDashboardRoute(router),
setupGameRoute(router),
); err != nil {
return cleanup, fmt.Errorf("error setting up routes: %w", err)
}
return cleanup, nil
}
Pages
Dashboard.templ
package pages
import "github.com/rphumulock/ds_nats_ttt/web/layouts"
templ Dashboard() {
@layouts.Base() {
<div class="flex flex-col min-h-screen max-h-screen overflow-hidden">
<nav class="bg-base-300 text-base-content py-4 shadow-lg">
<div class="container mx-auto flex items-center justify-between px-6">
<div class="text-2xl font-extrabold tracking-widest uppercase text-secondary-content">
Tic Tac Toe
</div>
<h1 class="text-base sm:text-xl font-semibold text-primary-content text-center flex-1">
Welcome,
</h1>
</div>
</nav>
<main class="flex flex-col flex-grow w-full p-4 overflow-hidden">
<div class="flex flex-col sm:flex-row justify-between items-center p-4 bg-accent shadow-md w-full mb-4">
<div class="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<button
class="btn btn-primary rounded-none flex items-center justify-center text-center text-primary-content px-4 py-2 sm:px-6 sm:py-3 w-full sm:w-auto"
>
Create Game
</button>
<button
class="btn btn-secondary rounded-none px-4 py-2 sm:px-6 sm:py-3 text-secondary-content w-full sm:w-auto"
>
Logout
</button>
</div>
</div>
<div
id="list-container"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full overflow-y-auto"
style="max-height: 75vh;"
></div>
</main>
</div>
}
}
Game.templ
package pages
import "github.com/rphumulock/ds_nats_ttt/web/layouts"
templ Game() {
@layouts.Base() {
<div class="flex flex-col min-h-screen max-h-screen overflow-hidden">
<nav class="bg-base-300 text-base-content py-4 shadow-lg">
<div class="container mx-auto flex items-center justify-between px-6">
<div class="text-xl sm:text-2xl font-extrabold tracking-widest uppercase text-secondary-content">
Tic Tac Toe
</div>
</div>
</nav>
<main id="main-container" class="flex flex-col flex-grow w-full p-4 h-screen">
<div class="flex flex-col sm:flex-row justify-between items-center p-4 bg-accent shadow-md w-full gap-3">
<div class="text-sm sm:text-lg font-bold text-secondary-content text-center">
Game:
</div>
<div class="text-sm sm:text-lg font-bold text-secondary-content text-center">
Host:
</div>
<div class="text-sm sm:text-lg font-bold text-secondary-content text-center">
Challenger:
</div>
<div class="flex flex-col sm:flex-row gap-3 w-full sm:w-auto"></div>
</div>
<div
class="flex flex-grow items-center justify-center bg-base-300 mt-2 overflow-y-auto max-h-[75vh] w-full"
></div>
</main>
</div>
}
}
Nice! Nothing too wild here—pretty straightforward again-just HTML. Once we run task live
, Templ will do the heavy lifting and generate our templates for us. Easy peasy!
We can also see our new pages.
Alright, great. We've got buttons that don’t do anything and pages we have to navigate to manually... yeah, this is not it. Let's fix it.
Don't worry, Enter Datastar
Datastar
Ok so what's Datastar about? On the front of the Datastar site it says...
"Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework."
Oh neat, another front-end framework. Haha, well sort of but not really. Datastar was born from the same ideas that created HTMX. I'm sure you've heard of it. The approach is a bit different than the common SPA's people use such as React.
A Brief Overview
Traditionally, SPAs rely on JavaScript frameworks like React to fetch JSON from an API and dynamically build the UI. While techniques like Server-Side Rendering (SSR) and WebSockets exist, most applications use a standard request-response cycle, where JSON is retrieved and then rendered into the UI.
HTMX
HTMX, on the other hand, changes this paradigm. Instead of fetching JSON and constructing the UI in JavaScript, it directly requests HTML fragments from the server. These fragments are then dynamically inserted into the DOM without a full page reload, providing a smoother, more efficient experience.
This approach resembles traditional multi-page applications (MPAs), but with the advantage of partial page updates rather than full reloads.
Despite this shift, HTMX still operates in a request-response pattern—you're just receiving ready-to-render HTML instead of raw JSON. This allows you to build rich, interactive applications with minimal JavaScript, keeping logic on the server where it belongs.
For a deeper dive into this, check out HyperMedia Systems, an excellent read where one of the authors happens to be the creator of HTMX.
Datastar
Datastar takes this concept a step further. Like HTMX, it delivers HTML fragments that are rendered directly into the DOM, but instead of a pull-based approach, it operates more like a push from the server.
Rather than fetching HTML on demand, Datastar leverages Server-Sent Events (SSE) to stream HTML fragments from the server to the front-end. While you still initiate a request, the response isn’t limited to a single payload—instead, the server can continuously send one-to-many HTML fragments over time.
SSE establishes a however-long-you-want-lived HTTP connection, unlike traditional request-response cycles where the connection closes immediately after the response is received. With SSE, the server decides when to close the connection, allowing for real-time updates without requiring multiple requests from the client.
SSE is quite literally just a different Content-Type Header on the server.
We've got a high-level overview of Datastar—but don’t worry, there will be a dedicated series on that later. Right now, we're here for NATS, dang it!
So, let’s use Datastar to dive back into some NATS goodness! 🚀
Make our UI do stuff!
Let's add some functionality to those buttons using Datastar! First, we need to get Datastar!
Oh snap! That's easy, we can just use a link tag in our header and the CDN. Let's fix our web/layouts/Base.templ
and add Datastar.
Base.Templ
package layouts
templ Base() {
<!DOCTYPE html>
<html lang="en" data-theme="retro">
<head>
<title>Tic+Tac+Toe</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.1/bundles/datastar.js"></script>
<link href="/static/index.css" rel="stylesheet" type="text/css"/>
<link rel="icon" type="image/svg+xml" href="/vite.svg"/>
</head>
<body class="min-h-screen w-full bg-base-content">
{ children... }
</body>
</html>
}
Sweet, let's use it! There's quite a few SDK's created for utilizing Datastar with various languages, but we're sticking with Go!
Let's add the SDK.
$ go get github.com/starfederation/datastar@v1.0.0-beta.5
$ go get github.com/starfederation/datastar/sdk/go@v1.0.0-beta.5
And let's import into our page. We're going to use the alias datastar for it.
package pages
import (
"github.com/rphumulock/ds_nats_ttt/web/layouts"
datastar "github.com/starfederation/datastar/sdk/go"
)
templ Index() {
@layouts.Base() {
<!-- Full-Screen Container -->
<div class="flex flex-col min-h-screen max-h-screen overflow-hidden">
<!-- Navigation Bar -->
<nav class="bg-base-300 text-base-content py-4 shadow-lg">
<div class="container mx-auto flex items-center justify-between px-6">
<!-- Left: Game Title -->
<div class="text-2xl font-extrabold tracking-widest uppercase text-secondary-content">
Tic Tac Toe
</div>
</div>
</nav>
<!-- Main Content -->
<main class="min-h-screen flex flex-col items-center justify-center flex-grow gap-6">
</main>
</div>
}
}
This SDK will simply give us syntactic sugar. We'll explain as we add our login functionality.
Login
Let's first think about what we need to do. We want to maintain sessions per User so that we can save Games and Games State on a per User basis.
We will need to login somehow to maintain these sessions. So let's do it.
We're going to add this to our template.
<div id="login" data-on-load={ datastar.GetSSE("/api/index") }></div>
Let's talk about it really quick. What this is doing is tapping into data-attributes which is built into the HTML spec. You can read about them there and we can talk about them more in a Datastar specific series.
Our Datastar data-attribute is data-on-load
, which as you make suspect, will do something when the element is loaded into the DOM.
Right away, we’ll make a GET request to an endpoint. Our SDK provides some syntactic sugar with datastar.GetSSE("/api/index")
, making the process cleaner.
Under the hood, this function call in our Templ template translates to: "@get('/api/index')"
.
Without the SDK, we’d just use this string directly—nothing too complex, just a slight convenience. Again, not a big deal, but it keeps things tidy. 🚀
Let's use this in our template.
package pages
import (
"github.com/rphumulock/ds_nats_ttt/web/layouts"
datastar "github.com/starfederation/datastar/sdk/go"
)
templ Index() {
@layouts.Base() {
<!-- Full-Screen Container -->
<div class="flex flex-col min-h-screen max-h-screen overflow-hidden">
<!-- Navigation Bar -->
<nav class="bg-base-300 text-base-content py-4 shadow-lg">
<div class="container mx-auto flex items-center justify-between px-6">
<!-- Left: Game Title -->
<div class="text-2xl font-extrabold tracking-widest uppercase text-secondary-content">
Tic Tac Toe
</div>
</div>
</nav>
<!-- Main Content -->
<main class="min-h-screen flex flex-col items-center justify-center flex-grow gap-6">
<div id="login" data-on-load={ datastar.GetSSE("/api/index") }></div>
</main>
</div>
}
}
Now go ahead and check it out, open your dev-tools (chrome is Ctrl + Shift + i
. When we navigate to our Index.page
you will see a GET
request to /api/index
.
It's working! We also did this declaratively and without using any Javascript!
Of course the endpoint is still broken so let's work on that.
Let's fix the Index.go
route and add our new endpoint.
Index.go
package routes
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/rphumulock/ds_nats_ttt/web/pages"
datastar "github.com/starfederation/datastar/sdk/go"
)
func setupIndexRoute(router chi.Router) error {
router.Get("/", func(w http.ResponseWriter, r *http.Request) {
pages.Index().Render(r.Context(), w)
})
router.Route("/api/index", func(indexRouter chi.Router) {
indexRouter.Get("/", func(w http.ResponseWriter, r *http.Request) {
sse := datastar.NewSSE(w, r)
sse.ExecuteScript("alert('Hello from the server!')")
})
})
return nil
}
In Chi, router.Route
and router.Get
serve different purposes when defining endpoints. router.Get
is a simple way to define a single GET request for a specific path, whereas router.Route
creates a subrouter that allows grouping multiple related endpoints under a common prefix. This makes router.Route
useful for organizing API routes, especially when dealing with multiple endpoints that share a common base path.
In the provided code, router.Route("/api/index", func(indexRouter chi.Router) {
sets up a subrouter for /api/index
. This means that any additional routes defined within this block will be nested under /api/index/*
, keeping the routing structure clean and maintainable. If only a single GET request were needed at /api/index
, router.Get("/api/index", handlerFunc)
could be used instead.
The line sse := datastar.NewSSE(w, r)
initializes a Server-Sent Events (SSE) connection using the Datastar SDK. SSE allows for a persistent connection where the server can push updates to the client without requiring repeated requests. The datastar.NewSSE(w, r)
function wraps the HTTP request with the necessary SSE headers, ensuring that the connection remains open for streaming data. This setup enables real-time updates to be sent to the client efficiently.
sse.ExecuteScript("alert('Hello from the server!')")
We're again using the SDK to send a Datastar event.
This will do what it looks like it should: show an alert on the front-end when we hit the endpoint.
Since we're only performing one action the connection will close out after the handler function exits. We could've send 1, 2 and many more events if we wanted to all thanks to SSE and Datastar.
Datastar does the heavy lifting for us!
Ok, we have a nice little introduction. Let's keep building our application in Part 9!
Top comments (0)