Templ Layouts Slots and Component Composition in Go
Build nested templ layouts with children, component slots, optional slots, conditional attributes, a script emitted once and data from the context.

A layout is the part of a page you write once: the doctype, the head, the navigation, the footer. Every other page fits inside it. In html/template this is {{define}} and {{block}} with name juggling. In templ it is a component that renders its children, and the rest follows from the fact that components are functions: arguments are slots, and nesting a layout is calling one.
Tested with Go 1.25.6 and templ v0.3.1001, macOS on Apple Silicon (arm64)
This article builds a two-level layout with a sidebar slot, an optional head slot, a conditional attribute, a script that must appear once and a header that reads the user from the context, then renders a page through all of it and checks the result. It builds on the tutorial and the first component, is the base for a component library, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001.
The layouts
package main
import (
"context"
"strings"
)
type ctxKey string
// userFrom reads the signed-in user the handler put into the request context.
func userFrom(ctx context.Context) string {
if v, ok := ctx.Value(ctxKey("user")).(string); ok {
return v
}
return "guest"
}
// Base is the outer shell. head is an optional slot for extra tags; children is the body.
templ Base(title string, head templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>{ title } | Notes</title>
if head != nil {
@head
}
</head>
<body>
{ children... }
</body>
</html>
}
// AppLayout nests inside Base and adds a header and a sidebar slot.
templ AppLayout(title string, sidebar templ.Component, head templ.Component) {
@Base(title, head) {
<header>Signed in as { userFrom(ctx) }</header>
<div class="shell">
<aside>
@sidebar
</aside>
<main>
{ children... }
</main>
</div>
}
}
templ Nav(active string) {
<nav>
for _, l := range []string{"Home", "Reports", "Settings"} {
<a
href={ templ.URL("/" + strings.ToLower(l)) }
if l == active {
aria-current="page"
}
>{ l }</a>
}
</nav>
}
templ ReportStyles() {
<link rel="stylesheet" href="/css/reports.css"/>
}
var chartScript = templ.NewOnceHandle()
// Chart can be used many times on a page; its script is emitted once.
templ Chart(id string) {
@chartScript.Once() {
<script>/* chart library loaded once */</script>
}
<div id={ id } class="chart"></div>
}
templ ReportsPage() {
@AppLayout("Reports", Nav("Reports"), ReportStyles()) {
<h1>Reports</h1>
@Chart("sales")
@Chart("traffic")
@Chart("errors")
}
}
templ PlainPage() {
@Base("Plain", nil) {
<p>No sidebar, no header.</p>
}
}Walk through it from the outside in.
Base is the shell. It takes a title and an optional head component, and renders { children... } inside <body>. The head slot shows the two things you need to know about parameters that are components. They are typed as templ.Component, the interface every component satisfies, so you can pass any component, including a hand-written one. And they can be nil: if head != nil { @head } guards the optional case, which is what PlainPage uses by passing nil.
AppLayout nests Base. It calls @Base(title, head) { ... } and puts its own structure inside the braces: a header, a sidebar slot and a <main> that contains { children... }. The children that AppLayout received are forwarded by writing { children... } again, inside the content it passes to Base. The page content therefore ends up in <main>, inside <div class="shell">, inside <body>, with no layout knowing about the other's insides. A page that wants the plain shell calls Base directly, and one that wants the app chrome calls AppLayout.
Conditional attributes read naturally. In Nav, the aria-current="page" attribute sits in an if block inside the tag, so it is present on the link whose label matches active and absent on the others. No string concatenation, no empty attributes left behind.
templ.URL turns a string into a safe URL value, and the sanitiser runs on it. That matters for href values assembled from parts; see the escaping article.
templ.NewOnceHandle() solves a problem every component set meets. A chart component needs a script on the page, but a page may include the component three times, and the script must not be repeated. chartScript.Once() { ... } renders its content the first time it is reached within a render and skips it afterwards. The same applies to a <style> block or a <link>. Without it, you either put the script in the layout and load it on pages that do not need it, or copy it into every instance.
ctx is in scope. Inside any component, the variable ctx is the context passed to Render. userFrom(ctx) reads a value the handler stored with context.WithValue, so the header shows the signed-in user without every page and layout passing a user parameter down five levels. Use it for ambient, request-scoped data: the user, a language tag, a CSP nonce, a CSRF token. Keep ordinary page data in parameters, where the compiler can check it.
Rendering through the stack
package main
import (
"context"
"fmt"
"strings"
)
func main() {
var sb strings.Builder
if err := ReportsPage().Render(context.WithValue(context.Background(), ctxKey("user"), "Mara"), &sb); err != nil {
panic(err)
}
html := sb.String()
fmt.Println(strings.ReplaceAll(html, "><", ">\n<"))
fmt.Println("\nchecks:")
fmt.Println(" <script> appears", strings.Count(html, "<script>"), "time(s) for", strings.Count(html, `class="chart"`), "charts")
fmt.Println(" head slot lands inside <head>:", strings.Index(html, "reports.css") < strings.Index(html, "</head>"))
fmt.Println(" aria-current only on Reports:", strings.Count(html, "aria-current"), strings.Contains(html, `aria-current="page">Reports`))
fmt.Println(" signed in as Mara:", strings.Contains(html, "Signed in as Mara"))
var plain strings.Builder
PlainPage().Render(context.Background(), &plain)
fmt.Println(" plain page has a sidebar:", strings.Contains(plain.String(), "<aside>"))
var anon strings.Builder
ReportsPage().Render(context.Background(), &anon)
fmt.Println(" anonymous request shows:", strings.Contains(anon.String(), "Signed in as guest"))
}go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go run .<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Reports | Notes</title>
<link rel="stylesheet" href="/css/reports.css">
</head>
<body>
<header>Signed in as Mara</header>
<div class="shell">
<aside>
<nav>
<a href="/home">Home</a>
<a href="/reports" aria-current="page">Reports</a>
<a href="/settings">Settings</a>
</nav>
</aside>
<main>
<h1>Reports</h1>
<script>/* chart library loaded once */</script>
<div id="sales" class="chart">
</div> <div id="traffic" class="chart">
</div> <div id="errors" class="chart">
</div>
</main>
</div>
</body>
</html>
checks:
<script> appears 1 time(s) for 3 charts
head slot lands inside <head>: true
aria-current only on Reports: 1 true
signed in as Mara: true
plain page has a sidebar: false
anonymous request shows: trueThe output (with a line break added after every tag, for reading) shows the nesting exactly as designed: <head> contains the title from Base and the stylesheet link from the head slot, <body> contains the header, <div class="shell"> has the <aside> with the navigation, and <main> holds the report content. The checks confirm each claim. The chart script appears once for three charts. The head slot's link lands before </head>. Only the Reports link has aria-current. The header reads Mara from the context, and guest for a request that carried none. PlainPage, which calls Base directly, has no sidebar.
Patterns and traps
Prefer parameters over a configuration struct for slots, until there are many. AppLayout(title, sidebar, head) is clear at three parameters. At six, group the optional ones in a struct (LayoutOptions) so call sites name what they set, as with any Go function; do not grow a layout that takes a dozen slots, because that is a sign the layout is doing the job of several.
Do not nest children deeper than you need. Each { children... } forwarding is cheap, but a stack of four layouts is hard to reason about. Two levels (shell and section) cover most sites.
Choose the layout in the handler, not in the component. The route knows whether it is rendering a page or an HTMX fragment: Page(content) for a full navigation, the bare content for an HX-Request, as in the HTMX article. Components that inspect the request to decide whether to draw the shell mix two jobs.
Components in structs. A struct field of type templ.Component works, so a page model can carry its own sidebar. Guard against nil, and remember a component rendered twice runs twice.
Rendering children from Go code. In tests or in hand-written components, templ.WithChildren(ctx, child) attaches children to a context, and templ.GetChildren(ctx) reads them, which is how the generated code implements { children... }. You will rarely need either directly, but they are what let you test a layout with a stub child.
Be careful with the context. It is typed any and keyed by whatever you choose, so wrap access in a function (userFrom) with an unexported key type, as above. Anything stored in the context is invisible in the component's signature; limit it to values that are truly ambient.
Testing a layout
A layout test renders a stub child and asserts on structure: the doctype comes first, the title contains the page title, the child appears exactly once, optional slots render when given and not when nil. Because components render to a plain io.Writer, the test is a handful of strings.Contains and strings.Count calls, or a golden file for the whole shell, as in testing with golden files. The slot and children features are documented in the templ guide, and the component API is on pkg.go.dev.


