A minimal server-driven UI toolkit for Go. Build reactive web UIs with Go's html/template, server-sent events, and a tiny embedded client runtime (~3 KB gzipped). No npm, no build step, no JavaScript to write.
- Your server renders HTML components using
html/template - Browsers connect to an SSE endpoint and receive live DOM patches
- User clicks trigger lightweight
POST /actionrequests — no page reloads grain.js(compiled into your binary) applies surgical DOM updates
go get github.com/psdhajare/grain<!-- templates/layout.html -->
{{define "layout"}}
<!DOCTYPE html>
<html>
<body>
{{template "counter" .}}
</body>
</html>
{{end}}
<!-- templates/counter.html -->
{{define "counter"}}
<div g-id="counter">
<p>Count: {{.Count}}</p>
<button g-action="Increment">+</button>
<button g-action="Decrement">−</button>
<button g-action="Reset">Reset</button>
</div>
{{end}}No <script> tags anywhere — Grain injects them automatically. g-id must match the {{define}} name — this is how the client locates the element to patch.
package main
import (
"fmt"
"log"
"net/http"
"sync"
"github.com/psdhajare/grain"
)
type Counter struct{ Count int }
type App struct {
mu sync.Mutex
counter Counter
ch *grain.Channel
}
func main() {
tmpl, err := grain.ParseTemplates("templates")
if err != nil {
log.Fatal(err)
}
g := grain.New(tmpl)
ch := g.NewChannel()
app := &App{ch: ch}
mux := http.NewServeMux()
// Page handler — ch.Page injects scripts automatically
mux.HandleFunc("/", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
app.mu.Lock()
data := app.counter
app.mu.Unlock()
tmpl.ExecuteTemplate(w, "layout", data)
}))
// Handle UI actions
mux.HandleFunc("/action", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
app.mu.Lock()
switch r.FormValue("action") {
case "Increment":
app.counter.Count++
app.ch.Push("counter", app.counter)
case "Decrement":
app.counter.Count--
app.ch.Push("counter", app.counter)
case "Reset":
app.counter.Count = 0
app.ch.Push("counter", app.counter)
app.ch.Flash("info", "Counter reset")
}
app.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
})
// SSE endpoint — send current state to each browser on connect
mux.HandleFunc("/events", ch.Handler(func(conn *grain.Conn) {
app.mu.Lock()
defer app.mu.Unlock()
conn.Push("counter", app.counter)
}))
// Serve grain.js from memory — no static folder needed
g.ServeJS(mux)
fmt.Println("→ http://localhost:9000")
log.Fatal(http.ListenAndServe(":9000", mux))
}go build -o myapp .
./myappOpen two browser tabs — click in one and watch the other update live.
There are two kinds of changes during development and each has its own solution.
HTML template changes do not require a recompile. Call g.Dev(...) after creating the Engine and Grain will re-parse your templates from disk on every render. Edit a .html file, hit the browser — the next SSE push or page load picks up the change immediately.
import "os"
g := grain.New(tmpl)
if os.Getenv("ENV") != "production" {
g.Dev(func() (*template.Template, error) {
return grain.ParseTemplates("templates")
})
}If a template has a syntax error the previous version is kept and the error is logged to stdout — the server stays up. Fix the template and the next request picks up the corrected version.
Never call Dev in production. It re-parses files from disk on every render.
When you change .go files the binary needs to be recompiled. air watches your source files, recompiles, and restarts the server automatically. Because grain.js maintains a persistent SSE connection, it detects the restart and reconnects within 2 seconds — no manual page refresh needed.
Install air:
go install github.com/air-verse/air@latestAdd .air.toml to your project root:
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o tmp/main ."
bin = "tmp/main"
include_ext = ["go"]
exclude_dir = ["tmp", "vendor"]
delay = 200
[log]
time = falseRun during development:
airThat's it. Change any .go file → air recompiles and restarts → grain.js reconnects → page is live again. Combined with g.Dev(...), template changes reflect instantly and Go code changes reflect within ~1 second.
This example shows how to build a reusable component, use it in multiple places on the same page, and style everything with Tailwind — no build step, no configuration.
shop/
├── main.go
├── go.mod
└── templates/
├── layout.html ← page shell
└── shop/
├── product-card.html ← reusable card component (no g-id)
├── product-grid.html ← main product grid (g-id="product-grid")
└── featured.html ← featured sidebar (g-id="featured")
grain.ParseTemplates("templates") picks up all .html files in every subfolder automatically.
<!-- templates/layout.html -->
{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shop</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 text-gray-900 min-h-screen">
<header class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<h1 class="text-xl font-bold tracking-tight">My Shop</h1>
</header>
<main class="max-w-6xl mx-auto px-6 py-8 flex gap-8">
<!-- sidebar: featured picks -->
<aside class="w-64 shrink-0">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 mb-4">Featured</h2>
{{template "featured" .Featured}}
</aside>
<!-- main grid -->
<div class="flex-1">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 mb-4">All products</h2>
{{template "product-grid" .Grid}}
</div>
</main>
</body>
</html>
{{end}}No <script> tags — Grain injects them automatically via ch.Page().
product-card has no g-id. It is a pure sub-template — a building block with no Grain wiring of its own other than g-key (which the parent needs for efficient diffing) and g-action on the button.
<!-- templates/product-card.html -->
{{define "product-card"}}
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden
hover:shadow-md transition-shadow duration-200"
g-key="{{.ID}}">
<img src="{{.Image}}" alt="{{.Name}}"
class="w-full h-48 object-cover" />
<div class="p-4">
<h3 class="font-semibold text-gray-900 leading-snug">{{.Name}}</h3>
<p class="text-sm text-gray-500 mt-1 line-clamp-2">{{.Description}}</p>
<div class="mt-3 flex items-center justify-between">
<span class="text-lg font-bold text-emerald-600">
£{{printf "%.2f" .Price}}
</span>
{{if .InStock}}
<button g-action="AddToCart" g-param="{{.ID}}"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800
text-white text-sm font-medium px-4 py-1.5 rounded-lg
transition-colors">
Add to cart
</button>
{{else}}
<span class="text-sm text-gray-400 font-medium">Out of stock</span>
{{end}}
</div>
</div>
</div>
{{end}}Each parent has a g-id and ranges over a slice, embedding product-card for every item. Both sections reuse the exact same template.
<!-- templates/product-grid.html -->
{{define "product-grid"}}
<div g-id="product-grid"
g-transition="250"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{{range .}}
{{template "product-card" .}}
{{end}}
</div>
{{end}}
<!-- templates/featured.html -->
{{define "featured"}}
<div g-id="featured"
g-transition="250"
class="flex flex-col gap-4">
{{range .}}
{{template "product-card" .}}
{{end}}
</div>
{{end}}package main
import (
"log"
"net/http"
"strconv"
"strings"
"sync"
"github.com/psdhajare/grain"
)
type Product struct {
ID int
Name string
Description string
Image string
Price float64
InStock bool
}
type PageData struct {
Grid []Product
Featured []Product
}
type App struct {
mu sync.Mutex
products []Product // single source of truth
ch *grain.Channel
}
func (a *App) featured() []Product {
// e.g. first 3 in-stock products
var out []Product
for _, p := range a.products {
if p.InStock && len(out) < 3 {
out = append(out, p)
}
}
return out
}
func main() {
tmpl, err := grain.ParseTemplates("templates")
if err != nil {
log.Fatal(err)
}
g := grain.New(tmpl)
ch := g.NewChannel()
app := &App{
ch: ch,
products: []Product{
{ID: 1, Name: "Leather Wallet", Price: 39.99, InStock: true, Description: "Full-grain leather, 6 card slots."},
{ID: 2, Name: "Canvas Tote", Price: 24.99, InStock: true, Description: "Heavyweight canvas, natural colour."},
{ID: 3, Name: "Wool Beanie", Price: 18.99, InStock: false, Description: "100% merino wool."},
},
}
mux := http.NewServeMux()
// Page — scripts injected automatically
mux.HandleFunc("/", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
app.mu.Lock()
data := PageData{Grid: app.products, Featured: app.featured()}
app.mu.Unlock()
tmpl.ExecuteTemplate(w, "layout", data)
}))
// Actions
mux.HandleFunc("/action", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
action := r.FormValue("action")
param := strings.TrimSpace(r.FormValue("param"))
app.mu.Lock()
defer app.mu.Unlock()
switch action {
case "AddToCart":
id, _ := strconv.Atoi(param)
ch.Flash("success", "Added to cart!")
_ = id // wire up your cart logic here
case "MarkOutOfStock":
// Update one product and push only that card to both sections.
id, _ := strconv.Atoi(param)
for i := range app.products {
if app.products[i].ID == id {
app.products[i].InStock = false
p := app.products[i]
// Surgical single-card update — the other cards are untouched.
ch.PushItem("product-grid", strconv.Itoa(id), "product-card", p)
ch.PushItem("featured", strconv.Itoa(id), "product-card", p)
ch.Flash("warning", p.Name + " is now out of stock")
break
}
}
case "Restock":
// Rebuild both sections in full (order may have changed).
id, _ := strconv.Atoi(param)
for i := range app.products {
if app.products[i].ID == id {
app.products[i].InStock = true
break
}
}
ch.Push("product-grid", app.products)
ch.Push("featured", app.featured())
ch.Flash("success", "Product restocked")
}
w.WriteHeader(http.StatusNoContent)
})
// SSE
mux.HandleFunc("/events", ch.Handler(func(conn *grain.Conn) {
app.mu.Lock()
defer app.mu.Unlock()
conn.Push("product-grid", app.products)
conn.Push("featured", app.featured())
}))
g.ServeJS(mux)
log.Fatal(http.ListenAndServe(":9000", mux))
}The product-card template is defined once and included in both product-grid and featured with {{template "product-card" .}}. There is no duplication. Styling is pure Tailwind classes — grain.js never touches them.
When a product goes out of stock, PushItem re-renders just that one card on the server and sends a targeted DOM patch. The 59 other cards in the grid, and every card in the featured sidebar, are completely untouched — both on the server and in the browser.
When you need to add, remove, or reorder products, use Push on the parent instead. PushItem is for in-place updates to items that already exist in the DOM.
For production, point Tailwind's content scanner at your templates folder and it works identically to any other HTML project:
// tailwind.config.js
module.exports = {
content: ["./templates/**/*.html"],
}Run npx tailwindcss -o static/app.css --watch during development, then link <link rel="stylesheet" href="/static/app.css"> in your layout. Grain and Tailwind are completely independent — Grain does not care what classes are on your elements, and Tailwind does not care about g- attributes.
| Attribute | What it does |
|---|---|
g-id="name" |
Required. Marks a component root; must match {{define "name"}}. |
g-action="Action" |
POST /action?action=Action on click. |
g-submit="Action" |
POST /action?action=Action on form submit (serialises all inputs). |
g-param="value" |
Adds param=value to the action POST body. |
g-signal="key: val" |
Declares a client-only reactive signal (no server round-trip). |
g-toggle="key" |
Flips a boolean signal on click. |
g-set="key: val" |
Sets a signal value on click. |
g-show="key" |
Shows the element when the named signal is truthy. |
g-hide="key" |
Hides the element when the named signal is truthy. |
g-on="event" |
Fire g-action on any DOM event (e.g. input, change). |
g-debounce="250" |
Debounce g-on by N milliseconds. |
g-key="{{.ID}}" |
Stable identity for list items — enables keyed morphing. |
g-transition |
Animate enter/leave on keyed list children (default 200 ms). |
g-transition="300" |
Custom transition duration in ms. |
g-error="field" |
Displays server validation errors for the named field. |
g-scroll-end |
Scrolls the container to the bottom after each morph. |
Walks dir recursively and parses every .html file into a single *template.Template. Sub-directories are supported at any depth — templates are referenced by their {{define "name"}} directive, not by file path.
tmpl, err := grain.ParseTemplates("templates")
if err != nil {
log.Fatal(err)
}
g := grain.New(tmpl)Pass the same function to g.Dev() for template hot-reloading during development:
g.Dev(func() (*template.Template, error) {
return grain.ParseTemplates("templates")
})ParseTemplates automatically injects a render template function into your template set. This lets you include a template by a runtime value instead of a hard-coded name — replacing the verbose {{if eq}} chain that Go's html/template otherwise forces on you.
Without render (the old way):
<main>
{{if eq .ContentTemplate "post-list"}}{{template "post-list" .}}{{end}}
{{if eq .ContentTemplate "post-detail"}}{{template "post-detail" .}}{{end}}
{{if eq .ContentTemplate "post-form"}}{{template "post-form" .}}{{end}}
{{if eq .ContentTemplate "edit-form"}}{{template "edit-form" .}}{{end}}
</main>With render:
<main>
{{render .ContentTemplate .}}
</main>A shared layout using render looks like this:
{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{{.PageTitle}}</title>
</head>
<body>
{{template "header" .}}
<main>
{{render .ContentTemplate .}}
</main>
{{template "footer" .}}
</body>
</html>
{{end}}In Go, set ContentTemplate to whichever page template should be slotted in:
type PageData struct {
PageTitle string
ContentTemplate string // e.g. "post-list", "post-detail", "post-form"
// ... page-specific fields
}
mux.HandleFunc("/", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "layout", PageData{
PageTitle: "My Blog",
ContentTemplate: "post-list",
// ...
})
}))
mux.HandleFunc("/post/{id}", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "layout", PageData{
PageTitle: "Post Title",
ContentTemplate: "post-detail",
// ...
})
}))render outputs template.HTML — the output of your sub-template is already context-escaped by html/template, so wrapping it prevents a redundant second round of escaping. This is safe as long as you use html/template (which grain.ParseTemplates always does).
Creates an Engine backed by your fully-parsed *template.Template. Call once at startup.
Creates an SSE broadcast channel. Use one per page or independent update group. Goroutine-safe.
Registers the embedded grain.js at /grain.js. No static file folder needed — the runtime is compiled into your binary.
Re-renders {{define "name"}} with data and sends a DOM patch to all connected clients. Call this whenever your state changes.
ch.Push("counter", myCounter)Sends a toast notification to all clients. kind is one of "success", "info", "warning", "error". The toast container is created automatically — no HTML needed.
ch.Flash("success", "Item saved!")
ch.Flash("error", "Something went wrong")Sends field-level validation errors to all clients. target is the g-id of the component; fields maps input name → error message. Errors clear automatically on the next Push of that component.
ch.Error("signup-form", map[string]string{
"email": "Must be a valid email address",
"username": "Already taken",
})In your template, add a g-error span next to each input:
{{define "signup-form"}}
<form g-id="signup-form" g-submit="Signup">
<input type="text" name="username" />
<span g-error="username" style="display:none; color:red; font-size:0.85em;"></span>
<input type="email" name="email" />
<span g-error="email" style="display:none; color:red; font-size:0.85em;"></span>
<button type="submit">Sign up</button>
</form>
{{end}}Re-renders itemTemplate with data and sends a targeted patch that updates only the element with g-key="key" inside g-id="parent". The server renders one item instead of the whole list — useful when a list has many entries and only one has changed.
// A single product's price changed — update only that card,
// leave the other 59 products in the grid untouched.
ch.PushItem("product-grid", strconv.Itoa(product.ID), "product-card", product)The item template is a normal sub-template with no g-id of its own:
{{define "product-card"}}
<div class="card" g-key="{{.ID}}">
<h3>{{.Name}}</h3>
<p class="price">{{.Price | printf "£%.2f"}}</p>
<button g-action="AddToCart" g-param="{{.ID}}">Add to cart</button>
</div>
{{end}}
{{define "product-grid"}}
<section g-id="product-grid">
{{range .Products}}
{{template "product-card" .}}
{{end}}
</section>
{{end}}If the item is not in the DOM the update is silently ignored. To add or reorder items, use Push on the parent.
Removes the element with g-key="key" inside g-id="parent" from all connected clients. If the parent has [g-transition], the leave animation plays before removal.
// Remove a single product without re-rendering the grid.
ch.DeleteItem("product-grid", strconv.Itoa(product.ID))Wraps a page handler and automatically injects the Grain client runtime into the HTML response just before </body>. Templates need no <script> tags.
sseEndpoint is the URL path the browser will connect to for live updates — it must match the path you register ch.Handler on.
mux.HandleFunc("/", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
data := currentState
mu.Unlock()
tmpl.ExecuteTemplate(w, "layout", data)
}))If the response contains no </body> tag (redirect, JSON, etc.) the page is written as-is without modification.
Returns the SSE handler to register as an endpoint. initFn is called once per new connection — use it to send the current state immediately (only to the connecting client, not broadcast).
mux.HandleFunc("/events", ch.Handler(func(conn *grain.Conn) {
mu.Lock()
defer mu.Unlock()
conn.Push("my-component", currentState)
}))conn.Push behaves like channel.Push but sends only to the one connecting client.
Use one channel per page so updates only reach the relevant clients. Pass each channel's SSE endpoint to ch.Page() — the correct endpoint is injected automatically into each page's HTML:
homeChannel := g.NewChannel()
chatChannel := g.NewChannel()
mux.HandleFunc("/", homeChannel.Page("/events/home", homePageHandler))
mux.HandleFunc("/chat", chatChannel.Page("/events/chat", chatPageHandler))
mux.HandleFunc("/events/home", homeChannel.Handler(homeInitFn))
mux.HandleFunc("/events/chat", chatChannel.Handler(chatInitFn))No <script> tags in either layout — each page automatically connects to its own SSE stream.
Templates can be organized into any folder structure you like — grain.ParseTemplates discovers all .html files recursively. Template names come from {{define "name"}} directives, not from file paths, so sub-directories do not affect how you reference templates in Go or in other templates.
myapp/
├── main.go
├── go.mod
└── templates/
├── layout.html ← {{define "layout"}}
├── counter/
│ └── counter.html ← {{define "counter"}}
├── shop/
│ ├── product-card.html ← {{define "product-card"}}
│ └── product-grid.html ← {{define "product-grid"}}
└── auth/
├── login.html ← {{define "login"}}
└── signup.html ← {{define "signup"}}
No static/ folder required — grain.js is served from memory. No <script> tags in templates — ch.Page() injects them automatically.
Server-side template escaping is provided by Go's html/template package, which Grain requires. Every {{.Field}} interpolation is context-aware escaped before it leaves the server — HTML content, attributes, URLs, and inline JavaScript contexts are all handled correctly. A user-supplied string like <script>alert(1)</script> renders as <script>... in HTML context and as a JSON-encoded string inside a <script> block.
Toast and validation messages are always written via textContent, never innerHTML. User-supplied error messages and flash text cannot inject HTML regardless of what they contain.
CSS selector sanitisation — the g-id, g-key, and field name values coming through SSE events are passed through cssSafe() before being embedded in querySelector calls. A value containing " or \ cannot break out of the selector or cause a SyntaxError that would kill the event handler.
Malformed SSE payloads are caught and logged as a warning instead of throwing an uncaught exception. Without this, a single bad event would silently kill all future updates for the lifetime of the page.
X-Content-Type-Options: nosniff is set on the /grain.js response, preventing browsers from MIME-sniffing and misinterpreting the file.
<script> tags inserted via innerHTML are never executed — this is a browser specification guarantee, not a Grain decision. The innerHTML usage in the DOM morphing path (same pattern as htmx, Turbo, Unpoly) is therefore safe against script-tag injection. Inline event handlers like onerror= on injected elements would execute, but html/template escapes attribute values including event handler contexts, making this a non-issue when templates are used correctly.
Use html/template, not text/template. The two packages have the same API but text/template does no HTML escaping. Grain will work with either, but only html/template provides XSS protection.
Never use template.HTML(userInput). The template.HTML type marks a string as pre-sanitised and bypasses all escaping. Using it with any value that derives from user input is an XSS vulnerability regardless of framework.
// ✗ dangerous — bypasses all escaping
tmpl.Execute(w, template.HTML(r.FormValue("bio")))
// ✓ safe — html/template escapes it in context
tmpl.Execute(w, r.FormValue("bio"))Serve over HTTPS in production. SSE connections over plain HTTP can be intercepted and injected. HTTPS removes the transport-layer injection vector entirely.
Set a Content Security Policy. For a typical Grain app with no inline scripts:
Content-Security-Policy: default-src 'self'
If you use the Tailwind CDN script add script-src 'self' https://cdn.tailwindcss.com. For production, prefer the Tailwind CLI and keep script-src 'self'.
- Zero external dependencies (standard library only)
- Build with
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w"for a stripped binary - A typical Grain app compiles to ~8–10 MB; ~3–4 MB with UPX
The example/ directory in this repo shows a full working example with a counter, live todo list (with filtering, validation, and animations), and a real-time chat board.
cd example
go run .
# → http://localhost:9000