tako.Page
Server-rendered HTML: browser forms in, templates out, htmx as a static asset — no client framework, no Node in production (journal chapter).
Declare
Exactly like tako.API — a var, a constant route, options
(examples/journal/app.go) — but the handler
returns tako.HTML:
var Home = tako.Page("GET /", home)
var Read = tako.Page("GET /p/{id}", read)
var Save = tako.Page("POST /p", save, tako.Route{SignIn: true})
var Publish = tako.Page("POST /p/{id}/publish", publish, tako.Route{SignIn: true})
Page takes the same options as tako.API — Route.SignIn and
Route.Private. Service-wide settings (domain, scaling) live on
tako.Service.
Pages render through a declared tako.Site; its
templates/ subtree backs site.Render and is never served raw.
Use
Req binds exactly like API — form: tags cover browser form posts,
never JSON:
type SaveReq struct {
Title string `form:"title"`
Body string `form:"body"`
}
A field that repeats binds as []string, which is what a checkbox
group is: every box carries the same name and the handler receives all
of them. No JavaScript assembles the list, so bulk selection keeps
working with scripting off.
type UnpublishReq struct {
Selected []string `form:"selected"`
}
<input type="checkbox" name="selected" value="{{ .ID }}">
site.Render(ctx, "post.html", p) fills the layout's content
block; site.Fragment(ctx, "post-card.html", p) renders the template
alone, for htmx partials. tako.Redirect(target) is
POST-redirect-GET. One handler serves both worlds with one visible
branch:
if ctx.IsHTMX() {
return site.Fragment(ctx, "post-card.html", p)
}
return tako.Redirect("/p/" + p.ID) // POST-redirect-GET (303)
Redirect vs RedirectPermanent
tako.Redirect is 303 See Other — a statement about THIS response,
which is what a POST-redirect-GET is.
tako.RedirectPermanent is 301 Moved Permanently — a statement about
the URL. Use it when a resource moved, so crawlers update their index
and shared links survive:
func read(ctx tako.Ctx, req ReadReq) (tako.HTML, error) {
p, err := posts.Get(ctx, req.ID)
if tako.IsNotFound(err) {
if m, mErr := moved.Get(ctx, req.ID); mErr == nil {
return tako.RedirectPermanent("/p/" + m.To)
}
return tako.HTML{}, tako.NotFound()
}
...
}
SiteConfig.Redirects covers the same ground for routes known at
analysis time — and only those. It is a map literal read by the
analyzer, so a slug your users rename next week cannot be in it. That is
the gap RedirectPermanent fills: the target comes out of the entity
store, per request.
htmx is identical for both — HX-Redirect and 200. Permanence is
something you say to crawlers and caches about a URL; a client-side
navigation is neither, and a 301 htmx cannot follow would be a
regression dressed as correctness.
CSRF on unsafe-method page routes is automatic: put {{ csrf }}
inside every form — it expands to a hidden _csrf input; htmx can
send the same token as an X-CSRF-Token header ({{ csrfToken }}).
Anonymous browsers get a double-submit cookie; signed-in sessions
carry their own token, so a stolen anonymous token cannot ride one.
{{ markdown .Body }} renders authored markdown with goldmark, then
sanitizes with bluemonday — an XSS payload vanishes, not escapes;
site.Markdown(ctx, src) is the same pipeline as a fragment.
tako.SiteConfig{TrustedHTML: true} on the Site is the explicit, graph-visible
opt-out for content that is never user-supplied.
Local
tako dev, then the browser. tako.Auth gives the dev bar: sign in
as alice/bob, no GCP anywhere. Templates re-parse on reload; parse
errors surface at render time with the template named.
Cloud
Pages ride the same Cloud Run app service as everything else in the
package — no extra infrastructure, scale-to-zero pricing. Documents
ship no-cache with security headers (CSP-Report-Only, nosniff);
fragments skip the document headers. Under htmx a redirect is 200 +
HX-Redirect (htmx cannot follow a 303 into a swap).
Test it
User-style: a cookie-jarred client, the token scraped from the form
(examples/journal/pages_test.go):
var tokenRe = regexp.MustCompile(`name="_csrf" value="([0-9a-f]+)"`)
form := url.Values{"title": {"First post"}, "body": {"Hello, journal."}, "_csrf": {token}}
res, err := c.PostForm(srv.URL+"/p", form)
Assert the 303 and its Location; post with HX-Request: true and
X-CSRF-Token to pin the fragment path. tako test runs the suite.
Errors you'll meet
A form posted without its token — or with a stale one — is a 403 with exactly this body:
CSRF token missing or invalid — forms need {{ csrf }}
An anonymous request to a Route.SignIn page is a 401 sign in to continue. site.Render against a layoutless site fails with:
site.Render needs templates/layout.html (the shell every page renders inside) — a site whose shell is generated (Hugo, etc.) renders fragments via site.Fragment instead
A typo'd template name:
template post.htm not found under web/templates/ (templates parse at startup; the layout is layout.html)
Handler errors with explicit statuses (tako.NotFound(),
tako.Errorf(403, "only the author can publish")) render as-is;
everything else is an opaque 500 with the request id, real error only
in your logs.
Not yet
A 404 can wear the site's design —
tako.SiteConfig{NotFound: "404.html"} renders it for both a mistyped
URL and a handler's tako.NotFound(), and site.md has the
whole story. Every other status still renders as plain text: there is
no way for a handler to return markup under a 403 or a 402, because
tako.HTML carries no status. The 404 was the one worth a field; the
rest are waiting for a second app to need them.
Rendering is Go templates only: static generator output plus Go SSR is the v0 model, no Node runtime services.
Caching a page
Every rendered page answers Cache-Control: no-cache. That is the safe
default and a costly one: a read-heavy site revalidates every visitor's
request against the app and the entity store, which on Datastore is a
billed read per visitor per page.
Route.MaxAge says otherwise, per route:
var Article = tako.Page("GET /{section}/{slug}", article, tako.Route{
MaxAge: 5 * time.Minute,
})
That renders Cache-Control: public, max-age=300. Zero — every route
that does not ask — keeps no-cache, so nothing changes until you say
so. The value is a constant between one second and a year, because it
is baked into the header at build time rather than decided per request.
It is refused on a SignIn route. A shared cache holding an
authenticated page hands it to whoever asks next, and nothing about that
failure is visible from the app — the tests pass, the page renders, and
one day a reader sees someone else's page. So it fails analysis:
app.go:64:2: Dashboard sets MaxAge on a route that requires sign-in — a shared cache would serve one signed-in visitor's page to another; drop MaxAge, or drop SignIn (tako/cache-signin)
A cacheable page carries no CSRF token. A response a shared cache
stores is handed to the next visitor, so it must contain nothing that
belongs to one — and a CSRF token is exactly that. tako mints no cookie
on a page with MaxAge, which means {{ csrf }} renders empty there:
a cacheable page cannot carry a form. Same bargain as cache-signin,
same reason.
That is also what makes the header REACHABLE. Cloud Run's frontend
downgrades public to private whenever a response carries a
Set-Cookie, so before this a cacheable page was publicly cached on
every visit except the first — the one a shared cache exists for:
cache-control: private, max-age=300 ← first visit, Set-Cookie present
cache-control: public, max-age=300 ← cookie already held
Static assets are already handled and need nothing: a fingerprinted file
(app.3f2a1c.js) gets public, max-age=31536000, immutable, everything
else under the site gets no-cache with a strong ETag.