Actions & sessions
A server component calls Go directly. An island runs in the browser, so its way back to Go is HTTP — but you never write the HTTP: list a Go method in Actions, import it in the island, await it. The compiler generates the route, the JSON decoding, the same-origin and header checks, the error mapping and the client stub, and the return type comes from the Go signature.
1. Declare an action
Module-level methods only; a *gotsx.Req first parameter gets the request injected
// host/host.go — list the methods islands may call; hostgen types them as Promise<T>
func (d *DataModule) Like(id string) (int, error) { return d.Models.Like(id) }
func (d *DataModule) Rename(req *gotsx.Req, id, title string) error { … } // *gotsx.Req: session / cookies
var Registry = map[string]gotsx.HostModule{
"data": {Value: Data, Go: "host.Data", Actions: []string{"Like", "Rename"}},
}hostgen reflects the method into app/.gen/host.d.ts as like(id: string): Promise<number> — parameter names come from the Go source. Arguments must be builtin or host types (results may be anything hostgen can reflect).
2. Call it from an island
A value import from host:* is allowed for actions; everything else stays import type
// app/islands/LikeButton.client.tsx — a typed action: no fetch, no untyped JSON
import { like } from "host:data"; // like(id: string): Promise<number>
export default function LikeButton({ id, likes }: { id: string; likes: number }) {
const [n, setN] = useState(likes);
return <button onClick={async () => setN(await like(id))}>{n}</button>;
}
// main.go: gotsx.Options{HostActions: gen.HostActions, …}
// errors: gotsx.Invalid(fields) → 422 (e.fields), gotsx.ErrNotFound → 404, else 500 — the call throws an Error with e.statusonClick={() => toggle(id)} fires and forgets; await inside an async handler gives you the result.3. Errors become statuses
The island's catch sees e.status, e.fields and e.message
// host/host.go — errors map to HTTP statuses; the island's catch sees e.status and e.fields
func (d *DataModule) Rename(req *gotsx.Req, id, title string) (Model, error) {
if req.Session().Get("user") == "" {
return Model{}, gotsx.Unauthorized("sign in first") // 401
}
if strings.TrimSpace(title) == "" {
return Model{}, gotsx.Invalid(map[string]string{"title": "required"}) // 422 + fields
}
m, err := d.Models.Rename(id, title) // %w gotsx.ErrNotFound → 404
if err == nil {
req.Session().Flash("ok", "Renamed") // shown by the next page as props.flash
}
return m, err
}// app/islands/RenameForm.client.tsx
import { rename } from "host:data";
const [errors, setErrors] = useState<Record<string, string>>({});
const submit = async () => {
try {
const m = await rename(id, title); // Promise<Model>
setErrors({});
emit("toast", m.title);
} catch (e) {
setErrors(e.status === 422 ? e.fields : { _: e.message });
}
};| Go | HTTP | island |
|---|---|---|
gotsx.Invalid(fields) | 422 | e.fields |
gotsx.Fail(msg) | 400 | e.message |
gotsx.Unauthorized(msg) / Forbidden(msg) | 401 / 403 | e.status |
fmt.Errorf("%w", gotsx.ErrNotFound) | 404 | |
| any other error or panic | 500 | message only in dev |
4. Sessions, flash messages, CSRF
A signed cookie; pages read it, actions and handlers write it
Pages receive props.session (read-only string values), props.flash (one-shot messages, consumed by the render) and props.csrf (a token for classic forms, created lazily so pages that don't use it set no cookie). Actions write through req.Session().Set / Flash / Clear; Go handlers through gotsx.SessionOf(r) and sess.Save(w, r). Set SESSION_SECRET in production; without it every start signs with a fresh random key.
// app/pages/todos.server.tsx — a classic form: no JavaScript involved
export default function Todos({ flash, csrf }: PageProps) {
return (
<>
{flash.map((f) => <div class={"alert alert-" + f.kind} role="status">{f.text}</div>)}
<form method="post" action="/todos">
<input type="hidden" name="_csrf" value={csrf} />
<input class="input" name="title" aria-label="New todo" />
<button class="btn btn-primary">Add</button>
</form>
</>
);
}// main.go — the handler behind the form (Options.Actions)
"POST /todos": func(w http.ResponseWriter, r *http.Request) {
if !gotsx.VerifyCSRF(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
sess := gotsx.SessionOf(r)
if _, err := host.Data.Todos.Add(r.FormValue("title")); err != nil {
sess.Flash("error", err.Error())
} else {
sess.Flash("ok", "Added")
}
sess.Save(w, r) // before writing the response
http.Redirect(w, r, "/todos", http.StatusSeeOther) // POST → redirect → GET
},5. Page meta
export function meta next to the page; the layout renders it
// app/pages/p/[id].server.tsx
import type { PageProps, Meta } from "gotsx";
export function meta({ params }: PageProps): Meta {
const m = models.get(params.id); // same 404 semantics as the page
return { title: m.title, description: m.desc, image: m.image };
}
// app/pages/_layout.server.tsx
export default function Root({ meta, children }: LayoutProps) {
return (
<html><head>
<title>{meta.title ? meta.title + " · Shop" : "Shop"}</title>
{meta.description && <meta name="description" content={meta.description} />}
{meta.noIndex && <meta name="robots" content="noindex" />}
</head><body>{children}</body></html>
);
}Fields: title, description, canonical, image, noIndex — all optional. meta runs before the page in the same request, so keep the host call it makes cheap.