Responses
A handler may produce HTML, JSON, a stream, a redirect, or an error. HTTP puts the status line and the headers ahead of the body on the wire, so whichever it is, both are fixed the moment the first byte of a body goes out. That is the protocol, not a rule this framework adds. The response helpers keep each case on the right side of that line while preserving the wire format it needs.
pw.WriteHTML(w, r, Home(HomeParams{Name: input.Name}))Home and HomeParams are generated from handlers/home.pw.html. The handler
passes a leaf fragment; WriteHTML renders it inside the application’s
registered document shell, so the handler never names, imports, or constructs
the document.
The whole chain is rendered into a buffer and validated before anything is committed. A template failure can therefore become a clean 500 instead of leaving a half-written page. If compression is enabled and the client accepts it, the same buffered body is encoded on the way out.
For explicit control over the wrapper chain — one page inside a different shell
— use pw.WriteHTMLChain:
pw.WriteHTMLChain(w, r, []pw.HTMLWrapper{templates.BindPrintDocument(templates.PrintDocumentParams{})}, Invoice(InvoiceParams{ID: input.ID}),)Fragments
Section titled “Fragments”An htmx-style interaction replaces one region of a page that already exists, so
it needs that region rather than a document. pw.WriteHTMLFragment renders one
template and nothing else:
pw.WriteHTMLFragment(w, r, Row(RowParams{Item: item}))No document shell, no wrapper chain, no merged head, no framing. The body is exactly what the template wrote, ready for the swap library to insert.
Two consequences follow from having no document around the markup.
A fragment never streams. An await boundary settles in place, so the response carries no placeholder for a client runtime to replace, and no boundary id that could collide with one still pending in the page it lands in.
A template that contributes to the document head is rejected with a 500 rather than losing those contributions silently. A scoped style block belongs in the head of the page that is already loaded, so declare it in a component that page renders, or in a shared stylesheet.
Failures answer with application/problem+json and their real status rather
than with an HTML error page, because an error document swapped into one region
would replace that region with a whole page. htmx and similar libraries do not
swap a non-2xx response, so the status is the signal they already act on.
examples/htmx_fragment is a complete application on this surface: one route
answers with a document, and the filter, the create form, and the delete button
each answer with the region they re-render. It also shows what to do with a
rejected form, since a swap library ignores the problem response a failed check
would produce.
Template syntax, slots, escaping, and scoped styles are covered in Templates. For what to build on top of this surface — dialogs the server fills, toasts, and where a swap stops being the cheapest answer — see Fragments and islands.
Cache policy
Section titled “Cache policy”Every HTML response says whether a shared cache may hold it, and the answer defaults to no:
Cache-Control: private, no-storeThe answer comes from the templates rather than from the request, and it has to.
Cache-Control is on the wire before the first body byte, while a per-reader
component four levels down renders long after that. A signal computed during the
render would therefore exist only on the buffered branch, and a page’s cache
policy would end up depending on whether streaming happened to be on.
So the chain is asked before anything renders, and a chain where nothing declared a scope reports private. That is the answer a login-gated application gets without writing a line. Declaring the shared answer takes one annotation on the document shell, because a shell wraps everything below it:
@cache(scope: "public")export component Document(children: html?): html { … }A shared page then receives no Cache-Control from the framework at all.
Freshness is a deployment’s decision, and a header naming no lifetime would
either invite heuristic caching or invent a lifetime nobody asked for, so the
framework stops asserting rather than asserting something weaker. Set the
lifetime at your CDN or in a middleware of your own.
On the private side the directive is no-store rather than no-cache, because a
document carries no entity tag. There is no conditional request to protect, and
no-store is what keeps a signed-in page off the disk of a shared machine.
The responses that are not documents keep the policy each one’s shape requires. A
navigation delta and a live delivery are no-store. A redraw is
private, no-cache, which preserves the conditional request its entity tag
exists for. A sequence — the static half of a fragment, derived from the template
rather than from the reader — is public, max-age=31536000, immutable.
Know this before putting a CDN in front of a public site: nothing is shared until a shell declares it, so a marketing page passes straight through the edge until you write the annotation. That is the intended direction rather than an oversight. Forgetting the annotation costs a cache miss; the mistake it prevents costs a reader somebody else’s account page.
@cache covers the annotation itself,
including what a private scope does to a component’s cache key.
pw.WriteAPI(w, r, user)The call site generates an encoder for the response type, removing runtime
reflection. The encoder reads a json tag’s name and its options, and it reads
the options the way encoding/json/v2 does rather than the way encoding/json
does. json:"-" keeps the field out of the document altogether. omitempty
drops a member that would encode as an empty JSON value — "", [], or {} —
which leaves 0 and false on the wire, because a number and a boolean have no
empty form to be. omitzero is the option that reaches those: it drops whatever
holds the Go zero value, and a nested struct counts as zero when every one of its
fields does. Reach for omitzero when the case you want gone is an unset count
or an unset flag, and for omitempty when it is an empty string or an empty
collection. An option that is neither fails generation rather than sitting there
looking like it works, so a misspelled omitempy stops the build instead of
silently writing a field you meant to drop.
A nil slice arrives as [] and a nil map as {}, never as null. Go draws no
line between a nil collection and an empty one, so the wire draws none either,
and a client is spared a null check that could only ever have meant “no items”.
That is worth knowing before you reach for make([]T, 0) in a handler: the empty
result already encodes as an empty array without it.
The status is 200. pw.WriteAPI call sites also feed the generated OpenAPI
document, so a JSON endpoint is described without a separate annotation pass.
Note that omitempty and omitzero do not make a field optional in that
schema — check:"required" is what decides the required list, so a field you
sometimes omit should not be marked required.
Streams
Section titled “Streams”A response that arrives over time — tokens, log lines, queue events — is written
with pw.WriteStream[T] instead:
func events(w http.ResponseWriter, r *http.Request) { pw.WriteStream(w, r, func(stream *pw.Stream[ChatEvent]) error { for event := range source { if err := stream.Write(event); err != nil { return err } } return nil })}The client chooses between Server-Sent Events, NDJSON, and a JSON array, and the handler above serves all three unchanged. Streams covers the negotiation, the framing, and what a long-lived response needs from the rest of the configuration.
Redirects
Section titled “Redirects”A redirect is a response like any other here, and it has two forms because handlers and loaders end differently. A handler holds the writer:
pw.RedirectSeeOther(w, r, "/users/"+id)A function that has no writer — a page loader a template binds with {val}, or
any code whose only way out is (T, error) — returns one instead:
if _, ok := auth.User(ctx); !ok { return View{}, pw.SeeOther("/auth/login")}Those constructors return an error, and that is not a trick played on the
type: a redirect is one of the ways a function can fail to produce a value, and
the response path already carries every such answer. pw.WriteProblem reads the
intent off what it is handed, so a redirect returned into it becomes a redirect
rather than a 500:
if err := service.Load(r.Context(), id); err != nil { // A pw.NotFound in err answers 404; a pw.SeeOther in it answers 303. pw.WriteProblem(w, r, err) return}The status is chosen by the constructor, along two axes:
| method may become GET | method preserved | |
|---|---|---|
| temporary | pw.SeeOther — 303 |
pw.TemporaryRedirect — 307 |
| permanent | pw.MovedPermanently — 301 |
pw.PermanentRedirect — 308 |
pw.SeeOther is the one to reach for. After a POST it is what keeps a reload
from reposting, and in a loader the axes decide nothing anyway — the render
answering it is a GET, where 303 and 307 are indistinguishable. Take
pw.PermanentRedirect when an address is retired; prefer it over
pw.MovedPermanently, whose treatment of the method is ambiguous in practice.
pw.Redirect(w, r, url, status) takes the status directly for the rare case
none of the four names.
Why not http.Redirect
Section titled “Why not http.Redirect”Two things happen on the way out that a handler should not have to repeat.
The target is checked. A redirect location is often a return path read from
the request, and the update runtime hands it to location.assign, which
executes a javascript: URL rather than navigating to it. Only relative URLs
and the http, https, mailto, and tel schemes are handed to a browser;
anything else is refused with a 500 rather than followed. So forwarding an
unchecked parameter cannot turn an application’s own redirect into script
execution.
An update request gets a directive instead. A request the update runtime
started is a fetch, and a fetch follows a 303 itself — the target would come
back and be applied as a region set for the wrong page. On that branch the
response is a navigate directive, which the runtime acts on as a navigation.
Both forms take this path, so a returned redirect and a written one cannot
disagree, and no action handler has to ask which kind of request it is
answering. See Partial updates.
Errors
Section titled “Errors”pw.WriteProblem writes an RFC 9457 Problem Details response as
application/problem+json:
pw.WriteProblem(w, r, pw.NotFound("no such user")){ "type": "about:blank", "title": "Not Found", "status": 404, "detail": "no such user", "code": "not_found"}Constructors
Section titled “Constructors”| Constructor | Status |
|---|---|
pw.BadRequest |
400 |
pw.Forbidden |
403 |
pw.NotFound |
404 |
pw.TooManyRequests |
429 |
pw.InternalServerError |
500 |
Each accepts an error, a string, another pw.Problem, or nothing at all.
Constructor call sites the generator recognises appear in the endpoint’s
OpenAPI description. pw.TooManyRequests and pw.RateLimited are not among
them, so a route that answers with one sends a complete 429 while the generated
OpenAPI document lists no 429 for it.
For an enforced quota, pw.RateLimited attaches retry metadata to the same 429
problem:
pw.WriteProblem(w, r, pw.RateLimited(pw.RateLimit{ Limit: 100, Remaining: 0, Reset: resetAt, RetryAfter: 30 * time.Second,}, "request quota exceeded"))The response carries Retry-After, X-RateLimit-Limit,
X-RateLimit-Remaining, and X-RateLimit-Reset. The X-RateLimit-* names are
compatibility conventions rather than standard HTTP fields; Retry-After is
the standard retry signal. A 429 response always carries Cache-Control: no-store, including a bare pw.TooManyRequests().
For a status without a constructor, build the value directly:
pw.WriteProblem(w, r, pw.Problem{ Status: http.StatusConflict, Title: "Conflict", Code: "already_registered", Message: "that email is already registered",})Passing errors through
Section titled “Passing errors through”pw.WriteProblem takes any error and maps it:
- a
pw.Problem(including one wrapped with%w) is used as-is; - a binding or validation error keeps its own status and field detail;
- anything else becomes a 500.
That mapping lets a handler forward a service error without adding a second translation layer:
if err := service.Register(r.Context(), input); err != nil { pw.WriteProblem(w, r, err) return}Two safety behaviours
Section titled “Two safety behaviours”5xx details never leak. A status of 500 or above is logged in full with the
request-scoped logger, then reported to the client as internal error with code
internal.
A committed response is never corrupted. If the body has already started,
WriteProblem logs the error instead of appending a second, contradictory
payload.
HTML error pages
Section titled “HTML error pages”Scaffolded projects carry status templates from templates/400.pw.html through
templates/500.pw.html, including templates/429.pw.html. They are ordinary
components, generated like any other page. The generated error resolver selects
one when Accept prefers HTML; the same problem answers as
application/problem+json for API clients. Status and response metadata remain
the same on both branches.
