Gastro

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:

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:

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:

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:

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:

  1. Dynamic dict keys. If any odd-indexed dict argument 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.
  2. 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/.