Server-Rendered UI with Templ
Zinc renders Templ components with one line β no framework glue. Templ UI is a component library built on Templ; this recipe stands up the toolchain both want (Templ + Tailwind v4) and shows the integration with Zinc.
The Zinc side stays thin. The weight is tooling: templ generate, a Tailwind build, and a tiny Render helper that pairs c.Context() with c.Writer().
What you getβ
- a typed
@components.Buttoninvoked from a page template POST /subscribethat reads a form, re-renders the page with a flash message, and keeps the URL stable- a Tailwind-built stylesheet served by
app.Static - a
Render(c, t)helper you can drop into any Zinc project using Templ
Setupβ
go mod init zinc-templ
go get github.com/0mjs/zinc
go get github.com/a-h/templ
go install github.com/a-h/templ/cmd/templ@latest
go install github.com/templui/templui/cmd/templui@latest
Grab the Tailwind v4.1+ standalone binary (no Node). Pick the asset for your OS/arch; macOS arm64 shown here:
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64
chmod +x tailwindcss-macos-arm64 && mv tailwindcss-macos-arm64 tailwindcss
Project layoutβ
.
βββ main.go
βββ components
β βββ button.go
β βββ button.templ
βββ views
β βββ home.templ
βββ public # tailwind output goes here
βββ input.css
Tailwind configurationβ
Tailwind v4 dropped the JS config file. Configure sources from CSS:
@import "tailwindcss";
@source "./views/**/*.templ";
@source "./components/**/*.templ";
Componentsβ
package components
func buttonClasses(variant string) string {
base := "inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition focus:outline-none focus:ring-2 focus:ring-offset-2"
switch variant {
case "primary":
return base + " bg-slate-900 text-white hover:bg-slate-800 focus:ring-slate-500"
case "secondary":
return base + " border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 focus:ring-slate-400"
default:
return base + " bg-slate-900 text-white"
}
}
package components
type ButtonProps struct {
Text string
Type string
Variant string
}
templ Button(props ButtonProps) {
<button type={ props.Type } class={ buttonClasses(props.Variant) }>
{ props.Text }
</button>
}
Page templateβ
package views
import "zinc-templ/components"
templ Home(flash string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Zinc + Templ</title>
<link rel="stylesheet" href="/static/app.css"/>
</head>
<body class="min-h-screen bg-slate-50 text-slate-900">
<main class="mx-auto max-w-lg p-10">
<h1 class="text-2xl font-semibold">Subscribe</h1>
<p class="mt-1 text-sm text-slate-600">Server-rendered with Templ. Styled with Tailwind. Served by Zinc.</p>
if flash != "" {
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{ flash }</p>
}
<form method="post" action="/subscribe" class="mt-6 space-y-3">
<input
name="email"
type="email"
required
placeholder="you@example.com"
class="w-full rounded-md border border-slate-300 px-3 py-2 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500"/>
@components.Button(components.ButtonProps{
Text: "Subscribe",
Type: "submit",
Variant: "primary",
})
</form>
</main>
</body>
</html>
}
Applicationβ
package main
import (
"log"
"github.com/0mjs/zinc"
"github.com/a-h/templ"
"zinc-templ/views"
)
func render(c *zinc.Context, t templ.Component) error {
c.Type("html")
return t.Render(c.Context(), c.Writer())
}
func main() {
app := zinc.New()
if err := app.Static("/static", "./public"); err != nil {
log.Fatal(err)
}
app.Get("/", func(c *zinc.Context) error {
return render(c, views.Home(""))
})
app.Post("/subscribe", func(c *zinc.Context) error {
if err := c.Request().ParseForm(); err != nil {
return err
}
email := c.Request().FormValue("email")
if email == "" {
return zinc.ErrBadRequest.WithMessage("email is required")
}
return render(c, views.Home("Subscribed "+email+"."))
})
app.Listen()
}
Dev loopβ
Three watchers, one per concern. Run each in its own terminal (or wrap with air, overmind, make -j3, whatever you already use):
templ generate --watch
./tailwindcss -i ./input.css -o ./public/app.css --watch
go run .
Visit http://localhost:8080, submit the form, and the same page re-renders with a flash message β no JSON, no client-side framework, no reload-on-navigate.
Using real Templ UI componentsβ
The Button above is structured like Templ UI's components β a Props struct, a named component, Tailwind classes from a helper β so swapping it for the real one is a file replacement, not a refactor.
Initialize your project for Templ UI and copy components into your tree:
templui init
templui add button input card
That drops real components into your project (Templ UI is copy-into-your-tree, like shadcn/ui). Import them from your views and call them the same way:
import "zinc-templ/components/button"
@button.Button(button.Props{ ... })
The Zinc handler doesn't change. render(c, t) renders any templ.Component.
Notesβ
c.Context()is the request context β pass it toRenderso cancellations and per-request values propagate into components that read fromctx.c.Writer()is the rawhttp.ResponseWriter, so streaming, flushing, and chunked output work through Templ unchanged.- Set
Content-Typewithc.Type("html")before callingRender; once Templ writes a byte, the header is committed. - For HTMX-style partial updates, render a smaller component from a handler that matches the target β the same
renderhelper serves full pages and fragments. - For production, run
templ generateand./tailwindcss -i input.css -o public/app.css --minifyin your build step. The Go binary then embeds./publicviaembed.FS+app.StaticFSif you want a single-file deploy.