Migrating templ components to gsx

SkillDev tools

Use when migrating templ components to gsx — covers the component-declaration rewrite, children syntax, cross-package calls, URL filter, and the critical children-boundary rule that determines migration order.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Migrating templ components to gsx skill

What this skill tells your AI

The instructions your AI receives, as published by gsxhq/gsx in skills/templ-to-gsx-migration/SKILL.md and read by ahel’s review.

Patterns from migrating a production templ UI to gsx. The corpus at internal/corpus/testdata/cases/ is the canonical syntax reference.

Component declarations

templgsx
templ Foo(args) { }component Foo(args) { }
templ (p P) M(props) { }component (p P) M(props) { }

Method components generate func (p P) M(props) gsx.Node. Invocation: P{}.M(props) (or {P{}.M(props)} inline).

Signatures are verbatim — no generated Props

gsx emits the authored parameter list unchanged; there is no generated <Name>Props struct (details in the gsx skill). Markup binds params by name (<Card title="Hi" featured/>); Go callers use the positional signature (Card("Hi", true)). children/attrs are explicit params, declared only when used:

component Panel(children gsx.Node, attrs gsx.Attrs) {
    <section { attrs... }>{children}</section>
}

Migration impact:

  • A templ component that took a single author-declared struct (component sec(props secProps)) keeps it — sec(meta) callers are unchanged.
  • Components that used to generate <Name>Props no longer do: keyed Go callers (Card(CardProps{Title:"Hi"})) become positional (Card("Hi", true)). This is the bulk of a real conversion (hundreds of call sites).

Generic components

Use native gsx generics. Do not keep templ-era Go wrapper functions whose only job is to normalize a type parameter and delegate to a private gsx component — that recreates templ's quirks instead of using gsx.

import "github.com/jackc/pgx/v5/pgtype"

component DisplayCheckbox[T bool | pgtype.Bool](label string, checked T) {
    {{ boolean := false }}
    {{ if v, ok := any(checked).(pgtype.Bool); ok { boolean = v.Valid && v.Bool } }}
    {{ if v, ok := any(checked).(bool); ok { boolean = v } }}

    <input type="checkbox" disabled { if boolean { checked } }/>
}

Same-package element calls infer the type argument:

<DisplayCheckbox label="Active" checked={props.Active}/>

When a Go test must call the generated function directly, instantiate the type param and pass args positionally — the signature is verbatim, there is no Props[T]:

field.DisplayCheckbox[pgtype.Bool]("Active", value)

Do not add production wrappers for generated generic components.

Children syntax

Placement inside the component body:

// templ
{ children... }

// gsx
{children}

Passing children at the call site:

// templ
@Child(args) { <span>content</span> }

// gsx
<Child ...><span>content</span></Child>

Embedding a bare gsx.Node value (childless):

{ node }

Cross-package component calls

Import the package; the tag prefix is the package alias.

import "example.com/app/ui"

component Page() {
    <ui.Button label="Save"/>
}

This lowers to ui.Button("Save") (positional). With children, <ui.Button label="Save">text</ui.Button> passes the body as the component's children param: ui.Button("Save", gsx.Func(...)).

Imported generic components infer type arguments in element-call syntax too:

import "example.com/app/ui/field"

<field.DisplayCheckbox label="Active?" checked={data.Active}/>

This lowers to the generated generic Go call, e.g. field.DisplayCheckbox[pgtype.Bool]("Active?", v). Prefer the element form over a Go node-expression call for migrated generic components — it is the intended gsx API and infers the type argument.

Imported .gsx components resolve like local ones — prefer direct fallthrough attrs and ordered-attrs literals over hand-written gsx.Attrs struct literals.

<card.DataGrid class="md:grid-cols-2">…</card.DataGrid>
<field.LongDisplayField
    label="X"
    value={v}
    class="col-span-full"
/>
<card.Panel Attrs={{ "data-a": "1", "data-b": "2" }}>panel</card.Panel>

The configured class merger still runs on the merged result. Use explicit Attrs={{ ... }} only when you need to pass a whole ordered attr bag as one prop; for normal class/data/hx attrs, write the attrs directly on the component.

For local presentational helpers, prefer the same JSX shape over hand-written attribute-bag props:

<StatusCard status="O" label="Open" class="bg-yellow-50"/>

component StatusCard(status string, label string) {
    <a class="card" { attrs... }>{label}</a>
}

This is cleaner than ExtraAttrs gsx.Attrs / gsx.Attrs{{Key:"class", ...}}. Referencing { attrs... } is what generates the Attrs prop; attrs is reserved and must not be declared as a normal parameter.

Lowercase (unexported) package symbols can use element syntax when gsx resolves the name to a component declaration or attrs-only component value: <entityMetaSection .../>. An unresolved lowercase name remains a literal HTML element. Keep helpers unexported unless another package needs them; capitalization is no longer required merely to get JSX-like call sites.

For semantic wrappers whose only API is forwarded attributes, use an attrs-only component value and return the real element directly. Do not add a public generic renderer component or manually merge attrs/classes:

func named(name string) func(...gsx.Attr) gsx.Node {
	return func(attrs ...gsx.Attr) gsx.Node {
		return <svg
			viewBox="0 0 24 24"
			stroke="currentColor"
			class="size-5"
			{ attrs... }
		>
			{ rawIcon(name) }
		</svg>
	}
}

var Search = named("search")

Call it naturally across packages: <icon.Search class="size-4"/>. Scalar attrs are caller-wins and classes go through the configured class merger, so the wrapper needs neither Attrs.Merge nor a Tailwind-specific helper. Keep the registry lookup (rawIcon) private; callers should only see named values. Do not retain a second package of one-component-per-icon wrappers or a public generic <Icon name="..."/> escape hatch. Migrate call sites to the named package API and delete the duplicate layer. When preserving a legacy default size, place its class before caller attrs/classes so authored-order merging still lets the caller win.

Conditional attrs instead of if/else element pairs

Don't keep templ-era if cond { <a href=…>X</a> } else { <span>X</span> } pairs for enabled/disabled links. Collapse to ONE element: conditional attribute for the href, conditional class parts for the look:

<a
    class={
        "rounded border px-2 py-1", "hover:bg-slate-50": p.HasPrev(), "text-slate-300": !p.HasPrev()
    }
    { if p.HasPrev() { href={p.URL(p.Page - 1)} } }
>
    ← Prev
</a>

An <a> without href is the standard disabled-link form. Same rule for any attr that exists only in one branch — the branches almost always differ by attrs/classes, not structure.

Related idioms from the same review pass: pass a ready-made byo props struct to an imported component with the splat element form (<components.Pagination {props.Pagination...}/>, not { components.Pagination(props.Pagination) }), and delete signature-identical wrapper funcs (func URL(...) = structpages.URLFor(...) adds nothing — call the real function).

Class composition

Do not preserve templ-era cls := "..."; if cond { cls += " ..." } scaffolding unless the class really must be assembled as a string. Prefer gsx's composable class value in markup:

<a
    class={
        "w-full rounded-md text-gray-700",
        "bg-gray-100 font-medium": active,
        "hover:bg-gray-100": !active,
        color
    }
>

This keeps styling decisions next to the element, mirrors JSX class composition, and lets the configured class merger see each source directly.

URL / routing

templ required explicit ctx threading:

// templ
href={ UrlFor(ctx, page, args...) }

gsx uses the url filter — ctx is auto-injected when the first parameter is context.Context, and a (string, error) return is auto-unwrapped:

href={ page |> url }            // no extra args
href={ page |> url(id) }        // fixed arg
href={ page |> url(args...) }   // variadic spread

url is wired to structpages.URLFor in structpages-integrated apps. Register sibling filters in gsx.toml [filters] for the related helpers — they share the same (ctx, value, args...) (string, error) shape:

url    = "github.com/jackielii/structpages.URLFor"   # href / hx-get / hx-post
id     = "github.com/jackielii/structpages.ID"        # raw HTML id attribute
target = "github.com/jackielii/structpages.IDTarget"  # hx-target ("#id" selector)
id={ GetEntityComments.Page |> id }          // → structpages.ID(ctx, …)
hx-target={ GetEntityComments.Page |> target }
hx-post={ CreateComment{} |> url(eventID) }

Migrate direct UrlFor(ctx, X, args...) / IdFor(ctx, X) calls to the filter form for the idiomatic style. A thin app wrapper (e.g. ui.UrlFor that only adds a shortcut over structpages.URLFor) is behavior-identical to the url filter when the call site doesn't use the shortcut — verify, then convert.

JavaScript and CSS attributes

Attribute language is explicit at the call site (there is no [[jsAttrs]] config) — mark JS/CSS-valued attributes with a js/css literal:

@click=js`open = !open`
x-data=js`{ open: false, id: @{id} }`
:class=js`active ? 'on' : 'off'`
style=css`display: none;`
style={css`max-width:@{width}px`}

@{ expr } holes are escaped for the embedded language, and the js/css literals are typed Go values (gsx.RawJS/gsx.RawCSS). Use them for Alpine expressions, event handlers, x-model, hx-on:*, and CSS-valued attrs; keep non-code Alpine strings plain (x-ref, x-transition:*, x-teleport="body").

JS-dev gotcha: don't write a dynamic JS expression as a plain { } attr — attr={ goExpr } serializes the Go value as a JS literal, so a string comes out quoted and Alpine runs a dead string:

@click={ fmt.Sprintf("tab='%s'", id) }   // WRONG — renders a quoted dead string
@click=js`tab = @{id}`                    // RIGHT

A Go value that is already a JS expression fragment is gsx.RawJS — pass it directly, or compose it inside a js literal where JS expects a value:

x-show={tab.Cond}               // tab.Cond is gsx.RawJS
x-show=js`Boolean(@{tab.Cond})`

A helper that builds JS returns gsx.RawJS and composes js literals — never fmt.Sprintf + wrap.

THE CHILDREN-BOUNDARY RULE (most important for migration order)

templ and gsx pass children differently:

  • templ threads children through context via templ.WithChildren / templ.GetChildren.
  • gsx passes children as a declared children gsx.Node parameter, set by the <Comp>…</Comp> call site.

Consequence: a component that takes children cannot be called across the boundary. Caller and the child-accepting component must be on the same side.

Childless components (no {children} placement) embed freely in both directions because gsx.Node and templ.Component are the same interface:

type Component interface {
    Render(ctx context.Context, w io.Writer) error
}

Because gsx.Node and templ.Component are structurally identical interfaces (Render(ctx context.Context, w io.Writer) error), a childless gsx node is used directly wherever a templ.Component is expected — no adapter is needed. (templ.FromGoHTML is for html/template objects, not gsx nodes.)

Migration strategy: bottom-up by subtree. Convert leaf components (no child slots) first. A parent page migrates to gsx only once every child-accepting sub-component it calls is already gsx.

Templ page
  └── templ ChildA (takes children)  ← must migrate FIRST
        └── gsx LeafButton           ← already migrated (childless, crosses freely)

Once ChildA is gsx, the parent page can be migrated.

Build coexistence

Generated files from both tools live side-by-side:

GeneratorOutput patterngitignore
gsx generate*.x.goyes
templ generate*_templ.goyes

Run both generators in the build; both outputs are regenerated, never committed.

Invoke gsx via module path to avoid Ghostscript name collision:

go run github.com/gsxhq/gsx/cmd/gsx generate

gsx generation is all-or-nothing per package. It type-checks the whole package (including templ's _templ.go); if any referenced gsx-generated symbol (a component function) is undefined, gsx emits zero .x.go for the package. So in a fresh checkout with no generated files, bootstrap in this order:

go tool templ generate            # emits _templ.go (no Go type-check)
go tool gsx generate ./pkg...     # now the package type-checks; emits .x.go
go tool templ generate            # _templ.go now sees the new gsx functions
go build ./pkg...

Generate every gsx package that's referenced (e.g. ./ds ./ui, not just ./ui) — a missing ds .x.go breaks the build the same way.

Current gsx keeps locals live when they are used only in a conditional boolean attribute condition:

{{ boolean := compute() }}
<input { if boolean { checked } }/>

If an older generator reports declared and not used: boolean for this pattern, update gsx. Do not add _ = boolean liveness shims to migrated components.

Orphaned _templ.go on full-file conversion. When a .templ file is entirely converted to .gsx (the .templ is deleted), its previously-generated _templ.go is orphaned — templ generate won't remove it, so the now-duplicate symbols cause X redeclared and gsx emits nothing. Delete the stale <name>_templ.go after removing the .templ. (Files that keep their .templ — only some templates removed — don't hit this.)

Prefer compound components for typed tables

Do not port a templ table API built around map[string]any, row adapters, renderer callbacks, or context thunks. In gsx, the table package should own only the semantic structure and styling while the caller owns typed iteration and cell rendering:

<table.Table>
	<table.Header>
		<table.Row>
			<table.Column>Subject</table.Column>
			<table.Column>Updated</table.Column>
		</table.Row>
	</table.Header>
	<table.Body>
		{ for _, ticket := range props.Tickets {
			<table.Row>
				<table.Cell class="max-w-64 truncate" title={ticket.Subject}>
					{ ticket.Subject }
				</table.Cell>
				<table.Cell>{ ticket.UpdatedAt }</table.Cell>
			</table.Row>
		} }
	</table.Body>
</table.Table>

Register package renderers for generic domain/scalar display (nullable text, timestamps, IDs), then interpolate values directly. Keep table-specific links, badges, truncation, and accessibility in the caller where the column meaning is known. Root attribute fallthrough provides class merging and caller overrides; do not add explicit attrs plumbing to each primitive.

Default renderers are the bootstrap, not an excuse for table callbacks. Register them once for values whose display is universal, then render the typed value:

<table.Cell>{ row.CreatedAt }</table.Cell>

Keep column semantics in markup. For example, truncate long text with CSS while preserving the full value in title; do not byte-truncate it in a renderer or build a temporary display string:

<table.Cell class="max-w-64 truncate" title={row.Subject}>
	{ row.Subject }
</table.Cell>

Likewise, prefer general pipeline filters (value |> format("%d"), plural, integer conversion) over domain-specific filters such as rank. Filters that can reject input return (T, error) so render failures propagate normally.

For sortable/paginated tables, build query state with net/url.Values and let the final attribute hole propagate the route error. Never concatenate raw query values or hide route failures behind must().

The page props should carry typed rows plus explicit pagination/query state, not a GetAttrs(ctx, page) thunk:

component FeedbackTable(props FeedbackProps) {
	<table.Table>…typed rows…</table.Table>
	<pagination.Root>
		<pagination.Previous
			href={FeedbackPage{} |> urlQuery(props.Query, "page", props.Page-1)}
		/>
		…
	</pagination.Root>
}

When several links share query state, expose a small typed method such as props.Query() or props.FilterQuery() returning url.Values. This is the Go equivalent of JSX code owning a URLSearchParams; it removes repeated key/value pairs without hiding routing or rendering behind callbacks. Compose ds/pagination at the call site so disabled states, targets, and route errors remain visible in GSX.

Query filters should accept typed scalar values and return errors. Do not force every call site through strconv.Itoa merely because the filter was declared with pairs ...string:

href={ListPage{} |> urlQuery("page", props.Page+1, "active", props.Active)}

Keep keys string-only. Convert values through a deliberate scalar contract (strings, booleans, numbers, pointers, encoding.TextMarshaler, and fmt.Stringer) and reject unsupported structs/maps/slices. This keeps the GSX call typed and ergonomic without silently accepting arbitrary fmt.Sprint output.

Delete the old generic table after the last consumer migrates. A compatibility API built from map[string]any, AttrFunc, TableAction, or pagination attr thunks has no residual value and preserves the templ constraints the compound design replaced.

Migrating existing raw <table> markup to ds/table

For pages that already have hand-written <table>…</table>, the migration is tag-rename + class-edit only. The app's tailwind-merge class_merger makes it clean: a class you pass to a table.* component overrides the component's baked class (passed wins on conflict), so you restyle per call site without touching the component.

  • Rename tags: <table><table.Table>, <thead><table.Header>, <tbody><table.Body>, <tr><table.Row>, <th><table.Column> (drop the baked scope="col"/text-left), <td><table.Cell>.
  • Change ONLY the table tags. Keep every surrounding wrapper <div> (card, overflow-x-auto, ring/rounded) byte-for-byte — deleting a card wrapper is a silent visual regression. Cheap gate: the diff must contain no added/removed non-table element lines (git diff | grep '<div' should be empty).
  • table.Cell bakes whitespace-nowrap. Cells holding long free text that wrapped before (titles, descriptions, comma-joined lists, error messages) need an explicit whitespace-normal to keep wrapping; a cell with truncate correctly stays nowrap; short/data cells adopt the nowrap default. (break-all is inert under nowrap — such a cell also needs whitespace-normal.)
  • border-b rows double with the baked divide-y. A legacy table that drew row lines with per-row border-b (and no divide-y) shows doubled borders after migration, because table.Table/table.Body bake divide-y. Pass divide-y-0 to cancel the baked dividers (keeping border-b), or drop border-b and adopt divide-y.
  • table.Table's wrapper breaks sticky headers. Its responsive wrapper is <div class="-mx-4 -my-2 overflow-x-auto …">; overflow-x:auto forces overflow-y:auto, so that div becomes a scroll container. A sticky top-0 <thead> inside a max-h-* scroll box then sticks to the wrong ancestor and stops working. Do NOT migrate a sticky-header-in-scroll table (or the sortable-component-header list/DataTable) to table.Table — leave it raw until ds/table grows a wrapper-less variant. Skipping some tables in a file is fine.
  • The wrapper's negative margins (-mx-4 sm:-mx-6 lg:-mx-8) are net-zero at sm:+ (cancelled by the inner sm:px-6 lg:px-8) and go edge-to-edge on mobile; it sits fine in a page container or a shadow/ring rounded-lg card.
  • Also skip non-data tables: key-value summaries with no <thead>, layout/ form tables, and reference tables embedded in doc prose.

Goal is sensible UI, not 1:1 with the old markup — adopt ds/table's defaults where they look right; override a baked class only when the default is actually bad UI.

Whitespace parity: gsx collapses like JSX, templ did not

gsx normalizes whitespace with JSX/React rules (internal/wsnorm): a newline between two elements is dropped entirely (no space), while a same-line space is preserved. templ preserved inter-element whitespace, so token-per-span content that relied on the newline-as-space renders cramped after migration:

<span>{ c.Field }:</span>
<span class="line-through">{ c.Old }</span>
<span>&#8594;</span>
<span>{ c.New }</span>
// renders  Active:true→false   (JSX-correct — NOT a gsx bug)

Add explicit spaces inside the spans where you want them:

<span>{ c.Field }: </span>
<span class="line-through">{ c.Old }</span>
<span> &#8594; </span>
<span>{ c.New }</span>
// renders  Active: true → false

Same fix for "Read more →" links (Read more <span> &rarr;</span>) and Old → New type-change labels. An arrow inside a flex justify-between span is positioned by layout, not inline whitespace, so it needs no fix.

Verification blind spot: a DOM-equivalence harness that normalizes whitespace, and assertContains substring checks, both miss this — tests stay green while the text is cramped. Confirm inline text spacing visually (browser, both apps side by side).

After the migration: merge template-adjacent .go into the .gsx

The login.go + login.templ file split existed only because templ's LSP broke constantly. The gsx LSP handles Go inside .gsx files, so once a feature is migrated, fold its Go half (route structs, Props, ServeHTTP handlers, validation helpers) into the .gsx — one file per feature. gsx passes top-level Go through verbatim; handlers/types read naturally beside the components they serve (structpages examples/todo/pages.gsx and examples/blog/admin/*.gsx are the reference layout).

What stays plain .go: main.go, route-wiring files (routes.go), data/store and auth layers, and any file whose line numbers are load-bearing (e.g. a lint fixture whose test findings anchor to pages.go:NN).

Mechanics: move declarations verbatim, merge the import lists into the .gsx's single import block (gsx fmt in goimports mode dedupes/orders), delete the .go. The deleted file had no generated sibling, so nothing else to clean up.

RenderComponent with element literals (structpages)

Once a handler lives in a .gsx file, element literals are available in Go-expression position, and structpages.RenderComponent accepts a direct component instance ("Direct component" pattern):

func (LoginPage) ServeHTTP(w http.ResponseWriter, r *http.Request, a *auth.Service) error {
    …
    return structpages.RenderComponent(<LoginShell errMsg={msg}/>)
}

Prefer this over the method-expression form (RenderComponent(Page.Method)) when the handler renders one concrete component — args are visible inline and the LSP checks them. target.Is(X) branch routing composes with it: Is routes, the literal renders. Two caveats: RenderComponent(target, args...) flows keep the target form, and a component instance can't take extra RenderComponent args (express everything in the literal).

No must() in examples — propagate (string, error) through holes

Never introduce a must[T](v T, err error) T panic helper to adapt an error-returning URL/ID call to a plain-string position. gsx attribute holes auto-unwrap (string, error) and propagate the error through the render, so the real fix is to move the error to the hole:

// helper returns (string, error); the attr hole unwraps it
func postFormAction(ctx context.Context, p store.Post) (string, error) {
    if p.ID == 0 {
        return components.URL(ctx, postCreateHandler{})
    }
    return components.URL(ctx, postUpdateHandler{}, "id", p.ID)
}

<form action={postFormAction(ctx, p)} method="post">

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
54
Forks
2
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
templ-to-gsx-migration
Source
github.com/gsxhq/gsx