Skip to content

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.

  • a real button.Button imported from Templ UI
  • POST /subscribe with 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:

Terminal window
go mod init zinc-templ
go get github.com/0mjs/zinc
go get github.com/a-h/templ
go get github.com/templui/templui@latest
go get -tool github.com/a-h/templ/cmd/templ@latest
mkdir -p views assets/css public

Zinc 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:

Terminal window
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64
chmod +x tailwindcss-macos-arm64
mv tailwindcss-macos-arm64 tailwindcss
.
├── main.go
├── views
│ └── home.templ
├── assets
│ └── css
│ ├── input.css
│ └── sources.generated.css
├── public
│ └── app.css # generated by Tailwind
└── tailwindcss

Templ UI lives in the Go module cache, which Tailwind does not scan automatically. Import a small generated source file alongside the page templates:

assets/css/input.css
@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:

Terminal window
TEMPLUI_PATH="$(go list -m -f '{{.Dir}}' github.com/templui/templui)"
printf '@source "%s/components/**/*.templ";\n' "$TEMPLUI_PATH" > ./assets/css/sources.generated.css
views/home.templ
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.

main.go
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"))
}

Generate the Go component and stylesheet before starting the server:

Terminal window
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 --minify
go mod tidy
go run .

Visit http://localhost:8080, or test the form directly:

Terminal window
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">.

For live development, run these in separate terminals:

Terminal window
go tool templ generate --watch
Terminal window
./tailwindcss -i ./assets/css/input.css -o ./public/app.css --watch
Terminal window
go 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 standard http.ResponseWriter used by templ.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.