Components
Components are reusable .gastro files in the components/ directory. They accept typed props and can render children.
Defining a Component
A component uses gastro.Props() to declare its props type. The Props struct defines what the component accepts:
---
type Props struct {
Title string
Author string
}
Title := gastro.Props().Title
Author := gastro.Props().Author
---
<article>
<h2>{{ .Title }}</h2>
<p>By {{ .Author }}</p>
</article>
gastro.Props() is a compile-time marker that tells the code generator this file is a component. The Props struct must be defined in the same frontmatter.
Frontmatter scope: package-level vs per-request
The same scope rule that applies to pages applies to components:
- Top-level
var,const,type, andfuncdeclarations run once at process startup. :=and statements run on every render.
type Props struct{} is itself a top-level type declaration, so it's
always at package scope. You can declare additional helpers next to
it:
---
import "strings"
type Props struct {
Title string
}
// Hoisted to package scope — runs once at server startup.
func displayTitle(t string) string {
return strings.ToUpper(t)
}
// Per-render — runs each time the component is rendered.
p := gastro.Props()
Title := displayTitle(p.Title)
---
<h2>{{ .Title }}</h2>
Referencing per-request state (gastro.Props(), gastro.Children(),
or request-scoped values) inside a hoisted decl is a build error;
use := instead.
Computed Values
When you need derived values from multiple props, assign the whole struct first:
---
import "fmt"
type Props struct {
Label string
X int
}
p := gastro.Props()
Label := p.Label
CX := fmt.Sprintf("%d", p.X + 135)
---
<text x="{{ .CX }}">{{ .Label }}</text>
Importing & Using Components
Import components in the frontmatter with the .gastro file extension. The identifier is the local name used in the template:
---
import (
Layout "components/layout.gastro"
PostCard "components/post-card.gastro"
)
Title := "Home"
---
{{ wrap Layout (dict "Title" .Title) }}
{{ PostCard (dict "Title" "My Post" "Slug" "my-post") }}
{{ end }}
Filenames are the public API
A component's Go identifier is derived from its file path. Path separators
(/) and dashes (-) fold to PascalCase; underscores are kept verbatim:
| File | Render method / Props type |
|---|---|
components/card.gastro |
Render.Card / CardProps |
components/post-card.gastro |
Render.PostCard / PostCardProps |
components/blog/post-card.gastro |
Render.BlogPostCard / BlogPostCardProps |
components/interest_chips.gastro |
Render.Interest_chips / Interest_chipsProps |
The import alias (the name left of the path) is what you write in templates
and is free-form, so the derived name only surfaces when calling a
component from Go. Prefer single-word or
kebab-case filenames so the generated identifier reads as idiomatic Go —
interest-chips.gastro gives you Render.InterestChips, while
interest_chips.gastro gives you the awkward Render.Interest_chips.
Prop Syntax
Props are passed as attributes on the component tag:
<!-- Template expression -->
{{ PostCard (dict "Title" .Title "Slug" .Slug) }}
<!-- String literal -->
{{ Layout (dict "Title" "About") }}
<!-- Pipe expression -->
{{ PostCard (dict "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }}
| Syntax | Meaning |
|---|---|
{.Expr} |
Go template expression, evaluated in parent's data context |
"literal" |
String literal |
{.Val | func "arg"} |
Pipe expression |
Multi-line dict calls
When a component takes more than a few props, a single-line dict call
becomes hard to read. Go's template parser is whitespace-insensitive inside
parenthesised expressions, so you can spread the call across multiple lines
and align the key–value pairs:
{{ Card (
dict
"Title" .Title
"Count" 5
"Active" true
"Meta" .Meta
"Items" .Items
"Class" "primary"
"Href" "/about"
"Target" "_blank"
"Size" "lg"
) }}
The leading ( on the first line and ) on the last line keep the
parser happy. Aligning keys and values with whitespace is optional but
makes the pairs visually obvious. Trailing commas are not required.
This has no effect on behaviour — dict arguments are positional, not
named — but the readability improvement is substantial for components
with many props. Build-time key validation still catches typos.
Pre-rendering in frontmatter
For pages with many components or deeply nested props, you can pre-render components in the page's frontmatter and pass the resulting HTML directly to the template. This keeps the template body lean and moves all typing into Go:
--- pages/dashboard.gastro
Header := gastro.Render.Header(HeaderProps{Title: "Dashboard", User: user})
Stats := gastro.Render.Stats(StatsProps{Events: 142, Cost: 53.20})
Items := gastro.Render.ItemList(ItemListProps{Items: deps.Items})
Title := "Dashboard"
---
{{ wrap Layout (dict "Title" .Title) }}
{{ .Header }}
<div class="grid">
{{ .Stats }}
{{ .Items }}
</div>
{{ end }}
Uppercase frontmatter variables (Header, Stats, Items) are exported
to the template as {{ .Header }}, {{ .Stats }}, {{ .Items }}. The
template has no dict calls at all for component rendering — it's just
a layout skeleton.
| Route | Use |
|---|---|
Template dict |
Quick, one-off component calls with a few props |
Multi-line dict |
Components with 5+ props — readability |
| Pre-render in frontmatter | Many components, deeply nested props, full type safety in Go |
gastro.Render from Go code |
SSE handlers, tests, background workers |
For components that should accept pre-rendered markdown as a prop
(e.g. a hero block where the body copy lives in a .md file), see
Markdown — it covers the //gastro:embed directive
plus the template.HTML prop pattern.
Type Coercion
Gastro automatically coerces prop values to match struct field types:
| Target Type | Accepted Values |
|---|---|
string |
Any value (converted via fmt.Sprintf) |
bool |
bool, string ("true", "false") |
int |
int, int64, float64, string (parsed) |
float64 |
float64, float32, int, string (parsed) |
Children
Children let a component render content provided by its parent. Place {{ .Children }} where children should appear:
---
type Props struct {
Title string
}
Title := gastro.Props().Title
---
<html>
<head><title>{{ .Title }}</title></head>
<body>
<nav>...</nav>
<main>
{{ .Children }}
</main>
<footer>...</footer>
</body>
</html>
The parent passes children by wrapping content in the component tags:
{{ wrap Layout (dict "Title" "Home") }}
<h1>Welcome</h1>
<p>This becomes the children content.</p>
{{ end }}
Children are rendered in the parent's data context, so they can reference the parent's template data. Only one {{ .Children }} is supported per component.
Forwarding attributes
A component that wraps a native element — a Button, Input, Link —
often needs to pass through arbitrary HTML attributes (type, href,
data-*, aria-*, Datastar's data-on:*) without declaring a typed prop
for each. Declare a field of type gastro.Attrs and it collects every
dict key that does not match a declared prop:
--- components/button.gastro
type Props struct {
Label string
Attrs gastro.Attrs
}
Label := gastro.Props().Label
Attrs := gastro.Props().Attrs
---
<button {{ attrs .Attrs (dict "class" "btn px-4" "type" "button") }}>{{ .Label }}</button>
Call it forwarding any attributes alongside the typed Label:
{{ Button (dict
"Label" "Save"
"type" "submit"
"class" "px-2"
"data-on:click" (safeJS "@post('/save')")
) }}
Label binds to the typed field; type, class, and data-on:click
fall through into .Attrs and are emitted by the attrs func.
The attrs func
{{ attrs .Attrs (dict ...base) }} renders the bag as HTML attributes,
taking an optional second dict of base defaults:
- Values are HTML-escaped by default. A value already marked safe
(
safeJS,safeAttr, …) is emitted verbatim — the same contract as those helpers. That is how Datastar expressions pass through unescaped. - A
boolvalue renders as a bare attribute whentrue(disabled) and is omitted whenfalse. - Keys are emitted in sorted order; names outside
[A-Za-z0-9_:.-]are skipped, so a forwarded key can never break out of the tag. - Base keys are defaults the caller can override — except
class, which is merged (below). Above,typedefaults to"button"but the caller's"submit"wins.
Merging classes (twJoin / twMerge)
The base class and the forwarded class are combined through a class
merger rather than overwritten. The built-in merger only concatenates:
attrs .Attrs (dict "class" "btn px-4") + caller class "px-2"
→ class="btn px-4 px-2"
Plain concatenation does not resolve Tailwind conflicts — px-4 and
px-2 both survive, and which wins is decided by the generated CSS order,
not the attribute. For real conflict resolution, plug a Tailwind-aware
merger via WithClassMerger in your main.go:
import twmerge "github.com/Oudwins/tailwind-merge-go" // lives in YOUR module
router := gastro.New(gastro.WithClassMerger(twmerge.Merge))
// → class="btn px-2" (px-4 dropped)
The dependency stays in your module — gastro core ships only the plain
twJoin merger and never imports a Tailwind library. The same merger
backs the twMerge template func; twJoin always plain-joins. See
Template Helpers.
Typos become attributes
Because a gastro.Attrs field accepts arbitrary keys, the
compile-time prop validation below is
relaxed for that component: an unknown key is a forwarded attribute, not a
typo. A misspelled Lable silently becomes a lable="…" attribute rather
than a build error. Components without an Attrs field keep the strict
typo guard.
Compile-time prop validation
Gastro statically validates the literal string keys you pass to (dict ...)
against the destination component's Props schema. A typo in a key surfaces
as a compile-time warning that names the component, the typo'd key, and
the valid prop names:
gastro: warning: pages/index.gastro:14: unknown prop "Tite" on component Card (valid: Body, Title)
This catches the "blank card in production" failure mode where
MapToStruct would otherwise silently drop the unknown key at render
time, leaving the field at its zero value.
The validator skips two cases by design so it doesn't false-positive:
- Dynamic dict keys. If any odd-indexed
dictargument isn't a string literal (e.g.(dict $key $value)) the entire call is skipped — we can't know at compile time which keys it contains. - Components without a Props struct. A component that takes no typed props can be invoked with an arbitrary dict; the runtime ignores extra keys.
Warnings are promoted to errors by gastro generate, gastro build,
and gastro check (i.e. anything CI runs). The dev server (gastro dev)
prints warnings but keeps rendering, so live editing isn't blocked.
Calling Components from Go
Components can be rendered directly from Go code — useful for SSE handlers that patch the DOM with fresh component markup, for tests, or for any handler that produces HTML outside the page-routing flow.
For every component in components/, gastro generates a typed method on the
package-level Render value:
import gastro "myapp/.gastro"
html, err := gastro.Render.Card(gastro.CardProps{
Title: "Hello",
Body: "World",
})
if err != nil {
// handle
}
Components with children expose a Children template.HTML field on their
Props struct (auto-added by codegen when the template references
{{ .Children }}):
html, err := gastro.Render.Layout(gastro.LayoutProps{
Title: "Home",
Children: template.HTML("<h1>Welcome</h1>"),
})
Named content areas (sidebar, footer, etc.) follow the same pattern — add
an explicit template.HTML field to your Props struct and reference it
as {{ .Sidebar }} in the template body. There is no separate slots:
keyword.
Render lives in the generated .gastro/render.go. Each method calls the same
underlying component function used by the template renderer, so frontmatter
logic (computed values, validation) runs identically whether a component is
invoked from a template or from Go.
Router-bound Render() for parallel tests and multi-router setups
The package-level gastro.Render dispatches to the most-recently-constructed
Router via an internal atomic pointer. That's fine for a single-router app,
but tests that run t.Parallel() and construct a Router per test, or servers
that host multiple tenants on different Routers, should prefer the
router-bound API:
router := gastro.New(opts...)
html, err := router.Render().Card(gastro.CardProps{Title: "Hello"})
Methods on the value returned by router.Render() dispatch directly to that
Router's template registry and never read the global active-router pointer,
so they're race-free regardless of how many Routers exist concurrently.
When to use Render vs Routes
| Goal | Use |
|---|---|
| Mount file-based page routes on an HTTP server | router := gastro.New(...); router.Handler() |
| Render a single component to an HTML string (single-router app) | gastro.Render.<Name>(...) |
| Render a single component to an HTML string (parallel tests, multi-router) | router.Render().<Name>(...) |
A typical SSE example combines both:
mux := http.NewServeMux()
mux.HandleFunc("GET /api/increment", func(w http.ResponseWriter, r *http.Request) {
n := count.Add(1)
html, _ := gastro.Render.Counter(gastro.CounterProps{Count: int(n)})
sse := datastar.NewSSE(w, r)
sse.PatchElements(html)
})
mux.Handle("/", gastro.Routes())
See examples/sse/ for the full version.