tako docs guide reference api
GitHub

tako.Site

The app's web-serving story rooted at one directory: static assets, templates, frontend builds. One site per package. The journal and blog chapters teach the two shapes.

Declare

// site serves ./web embedded in the service binary; its templates/
// subtree backs site.Render and is never served statically.
var site = tako.Site("./web", tako.SiteConfig{Redirects: map[string]string{"/about-us": "/about"}})

SiteConfig{Build: tako.Build{Command: …, Out: …}} runs a real generator and serves its output — Hugo in examples/blog/app.go, esbuild in examples/shop/app.go:

var site = tako.Site("./site/public",
	tako.SiteConfig{Build: tako.Build{Command: "cd site && hugo --minify", Out: "./site/public"}})

var site = tako.Site("./web/dist",
	tako.SiteConfig{Build: tako.Build{
		Command: "cd web && npx --yes esbuild src/app.ts --bundle --format=esm --outfile=dist/app.js && cp index.html dist/",
		Out:     "./web/dist",
	}})

SiteConfig.SPA serves index.html for unknown paths (single-page apps own their routing; without it, unknown paths 404). SiteConfig.DevProxy forwards site requests to a generator's own dev server (Vite/Hugo HMR) during tako dev. SiteConfig.TrustedHTML sets the markdown posture (pages.md).

A custom domain is not a site setting — it belongs to the SERVICE that serves the site, so it goes on tako.Service as ServiceConfig{Domain: …}. A service with no site at all can have one too; domains.md is the whole story.

Use

Serving comes with the headers nobody configures by hand correctly: strong content ETags everywhere (304 on If-None-Match), immutable caching for fingerprinted assets (app.3f2a1c.js), no-cache for everything else (HTML above all), security headers on documents. Pretty URLs work (/about → about/index.html); SiteConfig.Redirects answers with a permanent 308. Declared API/Page routes win over the site by mux precedence; the site takes the rest.

That precedence is worth one more sentence, because it is not about which route you declared first — it is about which pattern is more specific. GET /{year}/{slug} is more specific than the site's / for every two-segment URL, so it takes /assets/site.css too, and answers 404 for a stylesheet that is sitting right there. tako warns when it sees this (tako/site-shadow in errors.md), naming the files, because nothing else about it looks like a failure:

app.go:73:2: ⚠ GET /{year}/{slug} shadows 3 files the site serves — …

The usual fix is to keep static files deeper than your wildcard routes (/assets/css/site.css clears a two-segment route). Shadowing on purpose is fine — a /media/{key} route serving its own files is not a mistake — and the warning never fails a build.

The 404 page

tako.Site("./web", tako.SiteConfig{NotFound: "404.html"})

One field, both ways a request can miss: a mistyped URL that matches no static file, and a handler returning tako.NotFound(). To a visitor they are one experience, and only one of them could ever be answered by something a handler returns — nothing runs on a static miss, so there is nothing to return a status from. That is why this is site config rather than a result type.

It resolves as a template first (templates/404.html, rendered inside layout.html like any page), then as a static file in the site directory — so a generator that already emits 404.html just works.

A missing or broken 404 page falls back to plain text. A 404 never becomes a 500: the visitor already could not find what they wanted, and an error page that errors turns "we don't have that" into "we are broken".

htmx requests get the rendered document like everyone else — one behaviour, no mode switch. A partial saying "not found" swapped into a page is a worse answer than a page.

Status pages

NotFound names a template. When the page needs data — a masthead built from config, a nav, a count — declare a status page instead:

var Status = tako.StatusPage(status)

func status(ctx tako.Ctx, s tako.Status) (tako.HTML, error) {
	return site.Render(ctx, "status.html", StatusView{
		Code: s.Code, Detail: s.Message, RequestID: s.RequestID,
		Total: postCount(ctx),
	})
}

It answers every response tako produces itself, not just the 404: a mistyped URL, a handler's tako.NotFound(), an expired CSRF token, a method the site does not serve, a handler that failed. One page, one place, and a status tako learns to answer later needs no new field.

Before this, each of those was written separately and looked it — a 404 rendered with no data, a 403 as plain text, and security headers that differed depending on whether the declared 404 resolved as a template or as a static file. They are one writer now.

tako.Status carries what tako can honestly say:

field
Code the status being answered
Path the request path
RequestID ties the page to the log line that explains it
Message safe, already-public text; empty for any 5xx
Policy the tako.Policy that refused this request; empty for every status tako answers on its own account

There is deliberately no error field. A 5xx's detail is logged against RequestID and never handed to the page, so leaking it is not something the type lets you do.

Policy is what makes a 403 legible. Several things answer 403 on a guarded route — your policy, a stale CSRF token, a handler's own refusal — and a page that guesses from the code alone tells a reader who is simply not a moderator that their form expired. journal branches on it in one line; see auth.md.

The page runs with an ordinary Ctx — session, logger, queries — so a status page can greet a signed-in visitor and read the store like any other page.

It can never make things worse. If it panics, errors, or renders nothing, tako falls back to the same plain text with the same status code. That guarantee is what makes it safe to render the 500 through it: a 500 does mean something in the process is already wrong, and the recover is what pays for asking the app anyway.

One per package. NotFound and a status page together is fine — the status page wins, and NotFound keeps serving the static-file case a generator emits.

The templates/ subtree is the one part never served raw: it backs site.Render / site.Fragment (pages.md).

It is a tree, and every template is named by its path relative to templates/:

web/templates/
├── layout.html            → the shell
├── admin/nav.html         → site.Render(ctx, "admin/nav.html", v)
├── site/index.html        → site.Render(ctx, "site/index.html", v)
└── shared/card.html       → {{ template "shared/card.html" . }}
                             site.Fragment(ctx, "shared/card.html", v)

So admin/nav.html and site/nav.html are two templates, not one — an app can keep its admin's markup and its public site's in separate directories, and a site bundle can own the templates it renders with. A template at the top level is named exactly as before, because its path IS its base name.

Only the top-level layout.html is the shell. One deeper in the tree is an ordinary template with an ordinary name.

layout.html is OPTIONAL — a site whose shell comes from a static generator (Hugo, etc.) renders only fragments, and blog's fragment templates ride Hugo's static/ dir into public/templates/ so one build feeds both worlds. Only site.Render needs the layout, and it says so at call time.

Replacing the templates while it runs

Templates parse once at boot, which is right for a site whose templates ride the binary — and wrong for an app whose editors write them. Three calls replace the set in a running service:

// The candidate is a SET, not a file: templates reference each other, so
// one validated file can still produce a set that will not render.
set, err := site.PrepareTemplates(ctx, next)
if err != nil {
    var bad *tako.TemplateErrors
    if errors.As(err, &bad) {
        return site.Fragment(ctx, "admin/errors.html", bad.Failed)  // path → why
    }
    return tako.HTML{}, err
}

// Prove it RENDERS before anyone reads it — see below for why parsing is
// not enough. Every surface you would be sorry to break, and each one
// the way it is served: Render for pages, Fragment for fragments.
for _, page := range publicPages {
    if _, err := set.Render(ctx, page, sampleData(page)); err != nil {
        return site.Fragment(ctx, "admin/errors.html", map[string]string{page: err.Error()})
    }
}
for _, frag := range publicFragments {
    if _, err := set.Fragment(ctx, frag, sampleData(frag)); err != nil {
        return site.Fragment(ctx, "admin/errors.html", map[string]string{frag: err.Error()})
    }
}

return tako.HTML{}, site.UseTemplates(ctx, set)

Why rendering is a separate step. Go resolves {{ template "card.html" }} at execute time, not parse time. A candidate that lost a partial parses perfectly and fails on the first page that uses it — so a gate on parsing alone would publish exactly the break it exists to prevent. TemplateSet.Render renders against the candidate without publishing it, and it is the same call site.Render makes, so what you prove is what visitors get.

Check a fragment with Fragment, not Render. Render puts the named template inside the layout, so handed a fragment — which defines no content block — it renders an empty shell, executes nothing of the fragment, and returns no error. A surface checked the wrong way is a surface not checked at all.

PrepareTemplates refuses a candidate with any parse error and names every template that failed, so an editor hears about all their mistakes on one save. Nothing is published unless you publish it, and a render already under way finishes against the set it started with.

The swap is per instance, and this is the part to design around. It replaces the set in the process that served the request. A service running more than one instance leaves the others serving what they booted with until they restart, and a restart brings back the templates the binary shipped with.

Converging is the app's job, and tako has no startup callback to hang it on: PrepareTemplates needs a Ctx and a wired site, both of which arrive with the first request. So the shape that works is lazy — the first render in a process notices its stored generation is newer than the one it is serving and publishes it, once, before answering:

func currentTemplates(ctx tako.Ctx) error {
    want, err := settings.Get(ctx, "templates")   // the app's own record
    if err != nil || want.Generation == loaded.Load() {
        return err
    }
    set, err := site.PrepareTemplates(ctx, storedFS(ctx, want.Generation))
    ...
    loaded.Store(want.Generation)
    return site.UseTemplates(ctx, set)
}

Tako has no "tell every instance" mechanism either; if an editor must never see their change flicker between instances, run that service at one instance.

PrepareTemplates and UseTemplates are also not atomic as a pair: two editors saving at once can have the second publish and the first then publish a set prepared before it, silently reverting them. Serialize saving in your app — a lock around the read-modify-publish is enough.

Tako never stores a replaced set. The app owns its storage, decides what a draft is, and decides when to reload — there is no hook that would decide for it.

Local

tako dev serves the directory from disk. SiteConfig.Build commands re-run on change, and the output dir is not watched, so no rebuild loops. SiteConfig.DevProxy applies only under the local backend — the deployed site never proxies. Blog needs Hugo on your machine (brew install hugo); shop needs Node — production needs neither.

Cloud

tako deploy runs the build once on the deploying machine and embeds outDir in the service binary — no bucket, no load balancer, no CDN bill; the site scales to zero with its service. Node and Hugo never reach production.

tako.Service(tako.ServiceConfig{Domain: "blog.example.com"}) puts the service on a custom domain with a Google-managed certificate, via a Cloud Run domain mapping — no standing infrastructure charge, but two manual steps that are yours (proving ownership, publishing DNS). domains.md has the transcripts, the refusals, and the limits; docs/domain-options.md prices what tako deliberately did not build.

Test it

Run the same SiteHandler the generated main mounts — os.DirFS here, go:embed in the binary (examples/journal/site_test.go):

func siteHandler() *httptest.Server {
	return httptest.NewServer(runtime.SiteHandler(os.DirFS("web"), runtime.SiteOptions{
		Redirects: map[string]string{"/about-us": "/about"},
	}))
}

Assert the header contract: Cache-Control: no-cache plus ETag on HTML, public, max-age=31536000, immutable on fingerprinted assets, and X-Content-Type-Options: nosniff on every file — a stylesheet and a script are as worth pinning to their declared type as a document. CSP and Referrer-Policy stay on HTML only: they describe a document's privileges, and a stylesheet has none.

Errors you'll meet

At analysis time:

tako.Site directory ./web does not exist (add it, or set SiteConfig.Build if a generator creates it) (tako/site-dir)
tako.SiteConfig.Redirects must be a map literal with constant keys and values — redirects are read at analysis time, not run time (tako/config-literal)
a package serves one site — onesite.site is already declared; remove onesite.admin or merge the two (tako/one-site)

ServiceConfig.Domain's own refusals — bad shape, two domains on one service, a domain in front of a package nothing public reaches, and the two deploy gates — live in domains.md.

At render time, site.Render on a fragments-only site:

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

Not yet

Every site is embedded in the app service's binary — one service, one URL — so a site's static assets and its dynamic handlers always answer on the same origin.

There is no CDN option. It was removed in U1a once the custom-domain research settled on Cloud Run domain mappings rather than a load balancer: with no balancer there is nothing to switch on, and an option that cannot be implemented is worse than one that does not exist. If you need edge caching, docs/domain-options.md compares the two paths tako has designed but not shipped (a global external Application Load Balancer, and Firebase Hosting) and names the conditions that would trigger either.