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
// 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
// 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.status
Render is synchronous
Calling an action in a component body is a compile error. Call it from a handler or an effect: onClick={() => 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
// 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
// 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 });
  }
};
GoHTTPisland
gotsx.Invalid(fields)422e.fields
gotsx.Fail(msg)400e.message
gotsx.Unauthorized(msg) / Forbidden(msg)401 / 403e.status
fmt.Errorf("%w", gotsx.ErrNotFound)404
any other error or panic500message 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
// 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
// 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
// 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.