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
(/), dashes (-) and underscores (_) fold to PascalCase:
| File | Component value / Props type |
|---|---|
components/card.gastro |
Card / CardProps |
components/post-card.gastro |
PostCard / PostCardProps |
components/blog/post-card.gastro |
BlogPostCard / BlogPostCardProps |
components/interest_chips.gastro |
InterestChips / InterestChipsProps |
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. Because - and _ fold
the same way, interest-chips.gastro and interest_chips.gastro collide;
gastro generate reports the collision.
The generated package's own identifiers — New, Routes, Router,
Option, and every With* — are reserved: a component that would export
one of them (new.gastro, with-handler.gastro), or whose name equals
another component's XProps type, fails gastro generate.
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
HeaderHTML, err := gastro.Render(Header, HeaderProps{Title: "Dashboard", User: user})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
StatsHTML, err := gastro.Render(Stats, StatsProps{Events: 142, Cost: 53.20})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ItemsHTML, err := gastro.Render(ItemList, ItemListProps{Items: deps.Items})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Title := "Dashboard"
---
{{ wrap Layout (dict "Title" .Title) }}
{{ .HeaderHTML }}
<div class="grid">
{{ .StatsHTML }}
{{ .ItemsHTML }}
</div>
{{ end }}
gastro.Render(Component, Props) returns (template.HTML, error); the
component value and its XProps type are bare identifiers because
frontmatter compiles into the generated package. Uppercase frontmatter
variables (HeaderHTML, StatsHTML, ItemsHTML) are exported
to the template as {{ .HeaderHTML }}, {{ .StatsHTML }}, {{ .ItemsHTML }}. 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 |
router.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.
Required and optional props
Props are optional by default: a field the caller leaves out keeps its
Go zero value, and nobody warns about it. Mark a field that the component
cannot render without with the gastro:"required" struct tag:
---
type Props struct {
Title string `gastro:"required"`
Body string // optional
}
Title := gastro.Props().Title
Body := gastro.Props().Body
---
A required prop is enforced twice:
-
When generating. A
(dict ...)call that omits a required key is an error ingastro generate,build,check, and the dev loops, reported at the component name:pages/index.gastro:9:6: error: missing required prop "Title" on component CardCalls whose dict keys are not all string literals are not checked here (the keys are unknown until render time). A
{{ wrap }}call suppliesChildren. -
When rendering. A required field that holds its zero value (
"",0,false,nil) after the props are decoded is a contract violation. In dev mode (gastro dev,GASTRO_DEV=1, orWithDevMode(true)) the render fails and the error reaches the page error handler (see Error handling). In production gastro logs a warning throughlog/slogand renders the component with the zero value.
The editor shows the same missing-required-prop errors as gastro generate. Omitted optional props are never reported.
The runtime check is exported so Go code can apply the same contract:
gastro.CheckRequiredProps(v any) error returns a
*gastro.RequiredPropsError listing the zero-valued required fields, and
gastro.PropsViolation(dev bool, component string, err error) error
applies the dev-error / production-log policy.
Prop types
Values passed through (dict ...) are checked against the Props field
types, with no silent coercion:
-
When generating, literal dict values — strings, numbers, booleans, and
nil— must be assignable to the field type, following Go's rules for untyped constants.(dict "Count" "three")intoCount intfailsgastro generate,build,check, and the dev loops:pages/index.gastro:4:23: error: prop "Count" on component Card: cannot use "three" (untyped string constant) as int valueField types gastro cannot resolve from their source text (a named type declared elsewhere, such as
time.Timeortype Variant string) are left to the render-time check. -
When rendering, every dict value must be assignable to its field without coercion. A value of a predeclared type converts only to a field of the same kind and only exactly:
"x"intotemplate.HTML,3intoint64, and2intofloat64are fine;"3"intoint,3intostring,"true"intobool, and2.5intointare mismatches. A mismatch follows the same policy as an empty required prop: an error in dev mode, alog/slogwarning in production (where the lenient conversion below then applies).
The runtime check lives in the generated code: every component that has a
Props struct calls gastro.CheckPropTypes[Props] on its dict before
decoding it.
Opting out with --no-typecheck-props
Pass --no-typecheck-props to gastro generate, build, check, dev,
or watch to turn both checks off. Dict values are then converted to the
field type leniently:
| 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) |
The generated code differs with and without the flag, so run
gastro check with the same setting as gastro generate, or it reports
drift. The opt-out lets you upgrade a project before its mismatches are
fixed; prefer fixing the values.
Children is reserved
A Props struct cannot declare a field named Children (or embed a type
named Children): gastro passes the content of a {{ wrap }} block to the
component as .Children (see Children), so the name is
taken. gastro generate and check report the field:
components/card.gastro:4:2: error: Props field "Children" is reserved for the {{ wrap }} slot: gastro passes the wrapped content to the component as .Children; rename the field (e.g. Items or Content)
Rename the field. To render wrapped content, use {{ .Children }}
without declaring it.
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 is a
generate-time error that names the position, the component, the typo'd key,
and the valid prop names:
pages/index.gastro:14:21: error: 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.
Unknown props, omitted required props, bare calls to a component that
declares Props, calls to a component that is not imported, and (unless
--no-typecheck-props) literal values of the wrong type are errors
everywhere: gastro generate, gastro build, gastro check, and the dev
loops all fail and report every such error in the project at once, each as
file:line:col. Under gastro dev / gastro watch the errors are also
sent to the browser as a build-error event, and the running app keeps
serving the last good build until you fix them. Warnings (for example a
deprecated __children key) only fail the strict commands (generate,
build, check); the dev loops print them and keep going.
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 component
value in the generated package (components/card.gastro → gastro.Card,
a gastroRuntime.Component[gastro.CardProps]). Render it through the
router:
import gastro "myapp/.gastro"
router := gastro.New()
html, err := router.Render(gastro.Card, gastro.CardProps{
Title: "Hello",
Body: "World",
})
if err != nil {
// handle
}
router.Render returns (template.HTML, error). The props struct goes
straight to the component (no map round-trip), and passing another
component's props type is a compile error. Components without a Props
struct get an empty one: router.Render(gastro.Divider, gastro.DividerProps{}).
Components with children expose a Children template.HTML field on their
Props struct (auto-added by codegen when the template references
{{ .Children }}):
html, err := router.Render(gastro.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.
The component values and XProps types live in the generated
.gastro/render.go. Each value 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. Because rendering goes through a specific *Router,
parallel tests and multi-router setups need no extra care.
To bind a render to a request — so request-aware helpers from
WithRequestFuncs resolve — use .With(r):
router.Render(gastro.Card.With(r), gastro.CardProps{…}). See
Helpers.
When to use Render vs Handler
| Goal | Use |
|---|---|
| Mount file-based page routes on an HTTP server | router := gastro.New(...); router.Handler() |
| Render a single component to HTML | router.Render(gastro.<Name>, gastro.<Name>Props{...}) |
A typical SSE example combines both: the command is a WithHandler route
that closes over the router to render the patch.
var router *gastro.Router
router = gastro.New(
gastro.WithHandler("POST /increment", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := count.Add(1)
html, err := router.Render(gastro.Counter, gastro.CounterProps{Count: int(n)})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sse, err := datastar.NewSSE(w, r)
if err != nil {
return // the client can no longer be reached
}
sse.PatchElements(string(html))
})),
)
http.ListenAndServe(":4242", router.Handler())
For pages that should stay current without hand-written streams, see
Live pages and examples/sse/.