Server-rendered HTML pages in Airway are written with
templ. A .templ file is a normal-looking HTML file
with Go expressions sprinkled in; at build time it is compiled into a plain
Go function that returns a typed templ.Component. There is no template
parsing at request time — pages are just fast Go code.
This guide shows where views live, how to write one, and how an action renders it.
Views live under app/views/, one folder per API module. The folder name is
the API module name with the _api suffix removed:
| API module | View folder | View files |
|---|---|---|
home_api |
app/views/home/ |
index.templ |
post_api |
app/views/post/ |
show.templ, index.templ, … |
| (shared) | app/views/layouts/ |
base.templ |
Each folder is its own Go package, and its name is the folder name. A package may hold any number of view files.
The generator turns each *.templ file into a sibling *_templ.go file (for
example app/views/home/index.templ → app/views/home/index_templ.go). Those
generated files are committed, so go build and go test never need the
templ CLI installed (templ is pinned as a Go tool in go.mod). After you edit
a .templ file, regenerate and keep the output in sync:
go generate ./... # or: just generateapp/views/home/index.templ (the landing page) is the smallest useful example:
package home
import "github.com/daqing/airway/app/views/layouts"
// Index renders the landing page (GET /).
templ Index() {
@layouts.Base("Airway") {
<h1>Hello, Airway!</h1>
<p>This page is rendered with the templ template engine.</p>
}
}templ Index() is a component: a named template, effectively a pure function
from its parameters to HTML. The generated Go code exports func Index() templ.Component, which is what an action calls.
app/views/layouts/base.templ provides the HTML document shell (doctype, head,
title, body). It declares a children slot where the page's own content is
injected:
package layouts
templ Base(title string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>{ title }</title>
</head>
<body>
{ children... }
</body>
</html>
}A page passes its markup to the layout by placing it inside the call's braces,
as shown above. Put shared markup — navigation, footers, scripts — in
layouts/ and wrap every page in it so you don't repeat the shell.
Use the lib/render HTML helper. This is the counterpart of the JSON helpers
(render.OK, render.Error) for page responses:
package home_api
import (
"github.com/gin-gonic/gin"
"github.com/daqing/airway/app/views/home"
"github.com/daqing/airway/lib/render"
)
func IndexAction(c *gin.Context) {
render.HTML(c, home.Index())
}Available helpers:
render.HTML(c, component)— sends the page with status200andContent-Type: text/html.render.HTMLStatus(c, status, component)— same, with an explicit status code (for examplehttp.StatusNotFound).
The component is rendered into a buffer first, so a render failure surfaces as
a 500 instead of a half-written response.
Components take whatever data they need as parameters. A page for a single record might look like:
package post
import "github.com/daqing/airway/app/views/layouts"
type Post struct {
Title string
Body string
Published bool
Tags []string
}
templ Show(post Post) {
@layouts.Base(post.Title) {
<h1>{ post.Title }</h1>
<article>{ post.Body }</article>
}
}Types referenced by a view can be declared in the same .templ file's header
(any Go code there is emitted into the generated file). The action builds the
argument from a model or service result:
func ShowAction(c *gin.Context) {
post, err := services.FindPostByID(...) // whatever your service layer returns
if err != nil {
render.Error(c, err)
return
}
render.HTML(c, post_view.Show(post))
}Keep components pure: pass data in through parameters; don't query the database or touch the request from inside a template. Handlers gather data and render; templates only format it.
Anything inside { } is a Go expression. Values are HTML-escaped by default,
so user input can't inject markup. Control flow is plain Go — if, else,
for, and switch blocks with Go braces:
<h1>{ post.Title }</h1>
if post.Published {
<p>Published</p>
}
<ul>
for _, tag := range post.Tags {
<li><a href={ "/tags/" + tag }>{ tag }</a></li>
}
</ul>If you genuinely need to emit trusted HTML (for example a render of Markdown
that was already sanitised), wrap it with templ.Raw:
<article>{ templ.Raw(post.HTMLBody) }</article>Prefer the escaped { expr } form; reach for templ.Raw only for content you
trust.
Let's add an HTML show page to a post_api module.
-
Create
app/views/post/show.templ(from the example above). -
Regenerate the Go code:
go generate ./...
This creates
app/views/post/show_templ.gowithfunc Show(...) templ.Component. -
Render it from the action (as in the
ShowActionsnippet above). -
Wire the route in
config/routes.goor the module's ownroutes.go.
- Regenerate after every edit. Edit the
.templfile, then rungo generate ./...(orjust generate). Commit the generated*_templ.goalongside the.templsource. - Errors show up at compile time. Because templates compile to Go, typos and type mismatches fail the build — not the request.
- Editor support. templ ships an LSP (
go tool templ lsp) and IDE extensions; usego tool templ fmtto format.templfiles, orgo tool templ fmt -fail .in CI to enforce it. - Security. String interpolation is escaped by default; build URLs and
attributes with expressions rather than string-concatenating HTML. Use
templ.Rawonly for content you already trust.
- The built-in example:
app/views/home/+app/api/home_api/index_action.go(GET /). - The render helper:
lib/render/html.go. - The templ documentation for the full template syntax.