Server-Rendered UI with Templ UI
This recipe renders a real Templ UI Button from a Zinc handler. It uses Templ UI’s import workflow, Templ for generated Go components, and the standalone Tailwind CSS v4 CLI.
The Zinc integration is deliberately small: pass c.Context() and c.Writer() to any templ.Component, and serve the generated stylesheet with app.Static.
What you get
Section titled “What you get”- a real
button.Buttonimported from Templ UI POST /subscribewith normal form parsing and a server-rendered confirmation- Tailwind output served from
/static/app.css - a reusable
render(c, component)helper
Create a module and add Zinc, Templ, and Templ UI:
go mod init zinc-templgo get github.com/0mjs/zincgo get github.com/a-h/templgo get github.com/templui/templui@latestgo get -tool github.com/a-h/templ/cmd/templ@latest
mkdir -p views assets/css publicZinc already requires Go 1.25 or newer, so the tool directive is available. It
keeps the Templ generator attached to the module and makes it available as
go tool templ.
Download the Tailwind v4.1+ standalone binary for your OS and architecture. This example uses macOS arm64:
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64chmod +x tailwindcss-macos-arm64mv tailwindcss-macos-arm64 tailwindcssProject layout
Section titled “Project layout”.├── main.go├── views│ └── home.templ├── assets│ └── css│ ├── input.css│ └── sources.generated.css├── public│ └── app.css # generated by Tailwind└── tailwindcssTailwind input
Section titled “Tailwind input”Templ UI lives in the Go module cache, which Tailwind does not scan automatically. Import a small generated source file alongside the page templates:
@import "tailwindcss";@import "./sources.generated.css";
@source "../../views/**/*.templ";Generate the external source path from the installed module instead of hard-coding a machine-specific Go module cache path:
TEMPLUI_PATH="$(go list -m -f '{{.Dir}}' github.com/templui/templui)"printf '@source "%s/components/**/*.templ";\n' "$TEMPLUI_PATH" > ./assets/css/sources.generated.cssPage component
Section titled “Page component”package views
import "github.com/templui/templui/components/button"
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 UI</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 UI. 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"/> @button.Button(button.Props{ Type: button.TypeSubmit, FullWidth: true, Class: "bg-slate-900 text-white hover:bg-slate-800 focus-visible:ring-slate-500", }) { Subscribe } </form> </main> </body> </html>}Templ UI components take typed props and their visible content as children. For example, a submit button uses button.TypeSubmit; it does not use a free-form string field for its label.
Application
Section titled “Application”package main
import ( "log"
"github.com/0mjs/zinc" "github.com/a-h/templ"
"zinc-templ/views")
func render(c *zinc.Context, component templ.Component) error { c.Type("html") return component.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+".")) })
log.Fatal(app.Listen(":8080"))}Build and run
Section titled “Build and run”Generate the Go component and stylesheet before starting the server:
go tool templ generate
TEMPLUI_PATH="$(go list -m -f '{{.Dir}}' github.com/templui/templui)"printf '@source "%s/components/**/*.templ";\n' "$TEMPLUI_PATH" > ./assets/css/sources.generated.css
./tailwindcss -i ./assets/css/input.css -o ./public/app.css --minifygo mod tidygo run .Visit http://localhost:8080, or test the form directly:
curl -X POST http://localhost:8080/subscribe \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "email=matt@example.com"The response contains Subscribed matt@example.com. and a real Templ UI <button type="submit">.
Development loop
Section titled “Development loop”For live development, run these in separate terminals:
go tool templ generate --watch./tailwindcss -i ./assets/css/input.css -o ./public/app.css --watchgo run .Restart go run . after generated Go code changes, or place it behind your existing Go reload tool.
c.Context()carries request cancellation and request-scoped values into Templ.c.Writer()is the standardhttp.ResponseWriterused bytempl.Component.Render.- Set the content type before rendering because the first rendered byte commits the headers.
- The Button used here is non-interactive and needs no JavaScript. Templ UI components with client behavior also require their
Script()component and script routes; follow Templ UI’s current component documentation when adding one. - For a single-file deployment, embed the generated stylesheet and serve it with
app.StaticFS.
