Datapages Specification
March 29, 2026 · View on GitHub
Source Package
Generator requires a path to an application source package
that must contain an App type and the type PageIndex struct.
App
The App type may optionally provide a method for custom global HTML <head> tags:
func (*App) Head(
r *http.Request,
sessionToken string, // Optional
session Session, // Optional
) templ.Component {
return globalHeadTags()
}
The RecoverError method allows you to recover from handler errors to improve UX by
giving better feedback over SSE. All action handler errors (including httperr sentinels)
are routed through RecoverError when it is defined and the request is
a Datastar request. If RecoverError returns an error, the server falls back to
an HTTP error response using the appropriate status code.
func (*App) RecoverError(
err error,
sse *datastar.ServerSentEventGenerator,
) error {
return sse.PatchElementTempl(errorToast(err))
}
Pages
Individual pages are defined with type PageXXX struct { App *App } and
special methods:
GET: handlesGETrequests.POSTXXX: handlesPOSTaction requests.PUTXXX: handlesPUTaction requests.PATCHXXX: handlesPATCHaction requests.DELETEXXX: handlesDELETEaction requests.StreamOpen: runs when the page SSE stream opens.StreamClose: runs when the page SSE stream closes.OnXXX: subscribes to events in the SSE listener.
XXX is just a name placeholder.
Page types must only contain the exported App *App field, no more, no less.
Methods can be enriched with capabilities through parameters.
URLs must be specified by a strictly formatted comment in net/http Mux pattern syntax:
The page type PageIndex (for URL /) is required.
Page types PageError500 and PageError404 are optional special error pages for the
response codes 500 and 404 respectively.
Otherwise datapages will use its own defaults.
Handler method parameters and return values are defined and enforced by datapages. Parameters and return values may be in any order. Using unsupported parameter or return value names and types will result in generator errors.
The GET method parameter lists must include r *http.Request
and may include the following optional parameters:
func (PageIndex) GET(
r *http.Request,
sessionToken string, // Optional
session Session, // Optional
path struct{...}, // Required only when path variables are used in the URL
query struct{...}, // Optional
signals struct {...}, // Optional
dispatch(
EventSomethingHappened,
EventSomethingElseHappened,
//...
) error // Optional
) (
body templ.Component,
head templ.Component, // Optional
redirect string, // Optional
redirectStatus int, // Optional
newSession Session, // Optional
closeSession bool, // Optional
enableBackgroundStreaming bool, // Optional
disableRefreshAfterHidden bool, // Optional
err error
) {
// ...
}
Action handlers can also be defined on *App (pointer receiver) for global actions
not tied to a specific page:
// POSTSignOut is /sign-out/{$}
func (*App) POSTSignOut(r *http.Request, session Session) (
closeSession bool,
redirect string,
err error,
) {
return true, "/login", nil
}
The SSE action handlers POSTXXX, PUTXXX, PATCHXXX and DELETEXXX method parameter lists must
include r *http.Request and may include the following optional parameters:
// POSTActionName is <path>
func (PageIndex) POSTActionName(
r *http.Request,
sse *datastar.ServerSentEventGenerator, // Optional
sessionToken string, // Optional
session Session, // Optional
path struct{...}, // Required only when path variables are used in the URL
query struct{...}, // Optional
signals struct {...}, // Optional
dispatch(
EventSomethingHappened,
EventSomethingElseHappened,
//...
) error // Optional
) error {
// ...
}
Action handlers that omit the sse parameter can instead redirect,
return HTML, and set or remove sessions.
Session mutation and SSE are mutually exclusive in action handlers.
When the sse parameter is present, the handler opens a long-lived SSE stream —
HTTP headers (including session cookies) have already been sent, so newSession
and closeSession return values cannot be used.
// POSTActionName is <path>
func (PageIndex) POSTActionName(
r *http.Request,
sessionToken string, // Optional
session Session, // Optional
path struct{...}, // Required only when path variables are used in the URL
query struct{...}, // Optional
signals struct {...}, // Optional
dispatch(
EventSomethingHappened,
EventSomethingElseHappened,
//...
) error // Optional
) (
body templ.Component, // Optional
head templ.Component, // Optional
redirect string, // Optional
redirectStatus int, // Optional
newSession Session, // Optional
closeSession bool, // Optional
err error,
) {
// ...
}
All OnXXX method parameter lists must include
the event parameter of an event type and
sse *datastar.ServerSentEventGenerator. Parameters may be in any order.
The XXX placeholder must always match the event name after the type's Event prefix.
func (PageIndex) OnSomethingHappened(
event EventSomethingHappened,
sse *datastar.ServerSentEventGenerator,
streamID uint64, // Optional
sessionToken string, // Optional
session Session, // Optional
) error {
// ...
}
StreamOpen runs after the page SSE stream has been established and before
any event handler is invoked.
It may return only error. If it returns an error, stream setup stops immediately and
the stream is closed.
Datapages handles the error like any other Datastar request error: if RecoverError
is defined it is invoked, otherwise the server falls back to its internal-error path.
The streamID is a per-process unique identifier for the SSE stream instance.
Use it to correlate StreamOpen and StreamClose for the same stream.
It's intended for internal server-side bookkeeping only and
should not be exposed to clients.
func (PageIndex) StreamOpen(
r *http.Request,
streamID uint64,
sse *datastar.ServerSentEventGenerator, // Optional
sessionToken string, // Optional
session Session, // Optional
signals struct{...}, // Optional
dispatch(
EventSomethingHappened,
EventSomethingElseHappened,
//...
) error, // Optional
) error {
// ...
}
StreamClose runs when the page SSE stream closes.
It may return only error.
If it returns an error, datapages logs the error server-side.
func (PageIndex) StreamClose(
r *http.Request,
streamID uint64,
sessionToken string, // Optional
session Session, // Optional
dispatch(
EventSomethingHappened,
EventSomethingElseHappened,
//...
) error, // Optional
) error {
// ...
}
Abstract Page Types
Abstract page types can be embedded in page types to share functionality across pages:
type Base struct{ App *App }
func (Base) OnSomethingHappened(
event EventSomethingHappened,
sse *datastar.ServerSentEventGenerator,
session Session,
) error {
// ...
}
// PageFoo is /foo
type PageFoo struct {
App *App
Base
}
func (PageFoo) GET(r *http.Request) (body templ.Component, err error) {
return pageFoo(), nil
}
// PageBar is /bar
type PageBar struct {
App *App
Base
}
func (PageBar) GET(r *http.Request) (body templ.Component, err error) {
return pageBar(), nil
}
The embeddable abstract page type must always have App *App
same as concrete page types.
Example
// EventSomethingHappened is "something.happened"
type EventSomethingHappened struct {
WhoCausedIt string `json:"who-caused-it"`
}
// PageExample is /example
type PageExample struct { App *App }
func (p PageExample) GET(r *http.Request) (body templ.Component, err error) {
data, err := p.App.fetchData("")
if err != nil {
return nil, err
}
return examplePageTemplate(data), nil
}
// POSTInputChanged is /example/input-changed
func (p PageExample) POSTInputChanged(
r *http.Request,
session Session,
signals struct {
InputValue string `json:"inputvalue"`
}
) (body templ.Component, err error) {
// Patch the page with a fat morph directly on action.
data, err := p.App.fetchData(signals.InputValue)
if err != nil {
return nil, err
}
return examplePageTemplate(data), nil
}
// POSTButtonClicked is /example/button-clicked
func (p PageExample) POSTButtonClicked(
r *http.Request,
session Session,
dispatch(EventSomethingHappened) error,
) error {
// Update everyone that something happened.
return dispatch(EventSomethingHappened{WhoCausedIt: session.UserID})
}
func (p PageExample) OnSomethingHappened(
event EventSomethingHappened,
sse *datastar.ServerSentEventGenerator,
session Session,
) error {
// When something happens, patch the page.
return sse.PatchElementTempl(updateTemplate())
}
Parameter: signals struct {...}
signals struct {
Foo string `json:"foo"`
Bar int `json:"bar"`
}
Provides the captured Datastar signals
from the page. Signal fields map directly to Datastar signal names via their json tags.
Any named or anonymous struct is accepted,
but every field must have a json struct field tag.
Any JSON-serializable field type is supported, including nested structs, slices, and maps.
Nested structs map to nested Datastar signals using dot notation:
signals struct {
Form struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"form"`
}
This maps to Datastar signals $form.name and $form.email, initialized in
templates with data-signals:form.name="''" and data-signals:form.email="''",
or as a single object data-signals="{form: {name: '', email: ''}}".
The Go handler receives the nested values as signals.Form.Name and
signals.Form.Email.
Parameter: path struct {...}
path struct {
ID string `path:"id"`
}
Provides URL path parameters. These parameters must be defined in the URL comment. Both named and anonymous struct types are accepted.
Each field must be exported with a path:"..." struct tag
where the tag value names the corresponding route variable
(e.g. path:"id" binds to {id} in the URL pattern).
Supported field types are:
stringboolintint8int16int32int64uintuint8uint16uint32uint64float32float64
or any type implementing encoding.TextUnmarshaler.
Values are parsed from their string representation in the URL.
If a value cannot be parsed into the target type, the request
returns HTTP 400 Bad Request.
Parameter: query struct {...}
query struct {
Filter string `query:"f"`
Limit int `query:"l"`
}
Provides URL query parameters. Both named and anonymous struct types are accepted.
Each field must be exported with a query:"..." struct tag
where the tag value names the query parameter key
(e.g. query:"f" reads from ?f=...).
The same field types as path are supported.
The reflectsignal struct field tag can be used to define what signal shall reflect
into the query parameter:
signals struct {
SelectedItem string `json:"selecteditem"`
},
query struct {
SelectedItem string `query:"s" reflectsignal:"selecteditem"`
}
The above example will automatically synchronize the query parameter s with the
signal selecteditem.
Parameter: session Session
session Session
Provides authentication information from cookies.
If used, must be defined at the source package level as:
type Session struct {
UserID string
IssuedAt time.Time
// Custom metadata.
FooBar Bazz `json:"foo-bar"`
}
The Session type must have UserID string and IssuedAt time.Time fields.
IssuedAt is required because CSRF protection is bound to the session issuance time.
Any other field is treated as a custom payload.
Parameter: sessionToken string
sessionToken string
Provides the session token from cookies. Empty string if the request doesn't contain an authentication cookie.
If used type Session struct must be defined at the source package level.
type Session struct {
UserID string `json:"sub"` // Required.
IssuedAt time.Time `json:"iat"` // Required.
Expiration time.Time `json:"exp"` // Optional.
}
Parameter: sse *datastar.ServerSentEventGenerator
sse *datastar.ServerSentEventGenerator
This parameter is allowed on POSTXXX, PUTXXX, PATCHXXX, and DELETEXXX page methods
handling action requests and
OnXXX event handler page methods.
This gives you a handle to patch page elements, execute scripts, etc.
Parameter: dispatch func(...) error
dispatch func(EventXXX, /*...*/) error
This parameter provides a function for dispatching events and
only accepts EventXXX types as parameters. These events can be handled
by OnXXX page methods.
An event type must use json struct field tags, and be strictly commented with
// EventXXX is "xxx" (where "xxx" is the NATS subject prefix):
// EventExample is "example"
type EventExample struct {
Information string `json:"info"`
}
Events can declare subject fields to build targeted NATS subjects.
Any field whose name starts with Subject is a subject field.
Subject fields must have type string or []string.
Subject fields must be defined before any payload fields.
[]string— multiple values; the Cartesian product of all[]stringsubject field values is computed at dispatch time and each combination produces a separate publish.string— single value; used directly in the subject without looping.
When an event is dispatched, subject field values are appended (in field definition order) to the event's base subject, separated by dots.
For example, given // EventNotify is "notify":
dispatch(EventNotify{
SubjectUser: "u1",
SubjectRoom: []string{"r1", "r2"},
SubjectDevice: "mobile",
})
The following subjects are dispatched:
notify.u1.r1.mobile
notify.u1.r2.mobile
The string field SubjectUser contributes one value, the []string field
SubjectRoom expands into two combinations, and the string field
SubjectDevice contributes one value.
SubjectUser is a special subject field: its presence makes the event stream
require authentication (only authenticated users whose ID appears in the subject
will receive the event). It can be string (single user) or []string (multiple users).
// Multiple recipients:
type EventMessageSent struct {
SubjectUser []string `json:"subject_user"`
SubjectChatRoom []string `json:"subject_chat_room"`
Message string `json:"message"`
Sender string `json:"sender"`
}
// Single recipient:
type EventDirectMessage struct {
SubjectUser string `json:"subject_user"`
Text string `json:"text"`
Sender string `json:"sender"`
}
Signal-scoped subject fields
A subject field (other than SubjectUser) can carry a signal:"<name>" struct tag
to bind its value to a client-side Datastar signal. When a client connects to the
SSE stream, the server reads the signal value and uses it to build the subscription
subject. This enables per-instance event routing without authentication.
The signal name must start with a lowercase letter and contain only lowercase letters,
digits, underscores, or periods (e.g. signal:"instance_id", signal:"form.calc_id").
Signal-scoped subject fields must have type string (singular), since the signal
provides exactly one value. SubjectUser must not have a signal tag.
// EventCalcUpdated is "calc.updated"
type EventCalcUpdated struct {
SubjectInstance string `json:"subject_instance" signal:"instance_id"`
Result float64 `json:"result"`
}
When the SSE stream handler runs, it reads instance_id from the client's signals,
validates it is non-empty, and subscribes to calc.updated.<instance_id>.
Signal-scoped events can be mixed with private (SubjectUser) events and plain
public events on the same page. They can also coexist with non-signal subject fields:
// EventRoomUpdate is "room.update"
type EventRoomUpdate struct {
SubjectUser []string `json:"subject_user"`
SubjectRoom []string `json:"subject_chat_room"`
SubjectCalc string `signal:"calc_id"`
Data string `json:"data"`
}
Restrictions:
SubjectUsermust not have asignal:"..."tag.- No two subject fields may share the same
signal:"..."tag value. - Signal tag names must match
[a-z][a-z0-9_.]*.
The following is invalid because a subject field appears after a payload field:
type EventInvalid struct {
Message string `json:"message"`
SubjectUser []string // ERROR: subject field after payload field
}
Example 1 — Given // EventExample is "example":
dispatch(EventExample{
SubjectUser: []string{"u1", "u2"},
SubjectAnything: []string{"a1"},
SubjectSomethingElseEntirely: []string{"s1", "s2", "s3"},
})
The following subjects are dispatched:
example.u1.a1.s1
example.u1.a1.s2
example.u1.a1.s3
example.u2.a1.s1
example.u2.a1.s2
example.u2.a1.s3
Example 2 — Given // EventExample2 is "example2":
dispatch(EventExample2{
SubjectAnything: []string{"a1", "a2"},
SubjectUser: []string{"u1", "u2"},
})
The following subjects are dispatched:
example2.a1.u1
example2.a1.u2
example2.a2.u1
example2.a2.u2
You may provide multiple event types which are dispatched in the order of definition:
dispatch func(EventTypeA, EventTypeB, EventTypeC) error
Example
// EventMessageSent is "chat.sent"
type EventMessageSent struct {
SubjectUser []string `json:"subject_user"`
SubjectChatRoom []string `json:"subject_chat_room"`
Message string `json:"message"`
Sender string `json:"sender"`
}
// PageChat is /chat
type PageChat struct { App *App }
func (PageChat) POSTSendMessage(
r *http.Request,
e EventMessageSent,
session Session,
signals struct {
InputText string `json:"inputtext"`
ChatRoom string `json:"chatroom"`
},
dispatch(EventMessageSent) error,
) error {
if !isUserAllowedToSendMessages(session.UserID) {
return errors.New("unauthorized")
}
if signals.InputText == "" {
return nil // No-op.
}
return dispatch(EventMessageSent{
SubjectUser: chatroom.ParticipantIDs,
SubjectChatRoom: []string{signals.ChatRoom},
Message: signals.InputText,
Sender: session.UserID,
})
}
func (PageChat) OnMessageSent(
event EventMessageSent,
sse *datastar.ServerSentEventGenerator,
session Session,
) error {
// Use sse to patch the new message into view.
}
Return Value: body templ.Component
Specifies the Templ template to use for the contents of the page.
Return Value: head templ.Component
Specifies the Templ template to use for <head> tag of the page.
Return Value: redirect string
Allows for redirecting to different URLs.
Return Value: redirectStatus int
Specifies the redirect status code.
Can only be used in combination with redirect.
Return Value: newSession Session
newSession Session
Adds response headers to set a session cookie if newSession.UserID is not empty,
otherwise no-op.
Return Value: closeSession bool
closeSession bool
Closes the session and removes any session cookie if true, otherwise no-op.
Return Value error or err error
Regular error values that will be logged and followed by the error handling procedure
(500 Internal Server Error, or RecoverError if defined).
To return a specific HTTP status code instead of 500, use the sentinel errors from
the generated httperr package (datapagesgen/httperr):
import "myapp/datapagesgen/httperr"
func (p PageIndex) POSTInput(...) error {
if !valid {
return httperr.BadRequest // 400
}
if !allowed {
// 403, preserves original
return fmt.Errorf("%w: %w", httperr.Forbidden, errOriginal)
}
if !found {
return httperr.NotFound // 404
}
return nil
}
Available sentinels:
httperr.BadRequest— 400httperr.Forbidden— 403httperr.NotFound— 404
Return a sentinel directly, or wrap into the original error. When RecoverError is
defined, all errors (including httperr sentinels) are routed through it first. If
RecoverError is not defined or fails, the server responds with the appropriate HTTP
status code using the standard status text.
GET Return Value: enableBackgroundStreaming bool
Can only be used for GET methods.
enableBackgroundStreaming bool
By default, OnXXX event handlers can't deliver updates to background tabs.
If true, the SSE stream is always kept open. This prevents missed updates when the tab
is inactive, but increases battery and resource usage, especially on mobile devices.
This is equivalent to datastar's openWhenHidden).
enableBackgroundStreaming=true will automatically disable the auto-refresh after
hidden. If you want to prevent this, you have to explicitly add
disableRefreshAfterHidden to the return values and set it to false.
GET Return Value: disableRefreshAfterHidden bool
Can only be used for GET methods.
disableRefreshAfterHidden bool
By default, Datapages refreshes the page when it becomes active again after being in the
background (for example, when switching back from another tab).
This is useful when enableBackgroundStreaming is false, since SSE events may be missed
while the tab is inactive and the page state can become stale.
You can disable this behavior by returning disableRefreshAfterHidden=true.
Datapages relies on the
visibilitychange
event to perform the automatic refresh.
Linting
datapages lint parses the application model and reports all errors without generating
code. It validates the same rules as datapages gen, making it useful for CI checks
and editor integration.
This includes all structural validations (missing types, invalid signatures,
path comments, event definitions, parameter types, etc.) as well as
template-specific checks on .templ files:
- Hardcoded href: a static
href="/path"on an<a>tag or an expressionhref={ "/path" }/href={ SomeConst }whose value resolves to a disallowed URL. Use the generatedhrefpackage instead (e.g.href={ href.PageLogin() }). - Unverifiable href expression: an expression
hrefon an<a>tag that contains a function call not from thehrefpackage (e.g.href={ templ.SafeURL("/about") },href={ loginHref() },href={ fmt.Sprintf(...) }). The linter cannot statically verify these, so they must usehrefpackage functions. href.Externalwith internal URL:href.External("/login")wrapping a URL that looks app-internal.- Hardcoded action URLs: using a hardcoded URL in a Datastar action context
(e.g.
@post('/foo/bar')) instead of the generatedactionpackage (e.g.action={ action.POSTPageProfileSave() }). - Form action attribute: using a
<form action=...>attribute (constant or expression). Datapages does not support plain HTML form submissions — usedata-on:submitwith Datastar actions instead. - Action context: using an
action.XXX()call in an attribute that is not a Datastar action context (data-on:<event>,data-on-<plugin>,data-init). For example,action.POSTPageIndexSubmit()in anhrefattribute. - Href context: using an
href.XXX()call in a Datastar action context (data-on:<event>,data-on-<plugin>,data-init). Href functions return URL paths, not Datastar action strings — useaction.XXX()instead. - Action on wrong page: using an action that belongs to a different page
(e.g.
action.POSTPageProfileSave()in a template rendered byPageSettings). App-level actions are allowed on any page.
Allowed href values
The following href values are allowed without the href package and will not
produce lint errors:
- Fragment-only:
#section,# - Protocol-relative:
//cdn.example.com - Absolute with scheme:
https://...,mailto:...,tel:...,sms:...,ftp://... constvalues that resolve to one of the above- Backtick and double-quoted string literals that resolve to one of the above
The following are always disallowed:
- Root-relative paths:
/login,/static/style.css - Relative paths:
relative,./x,../x - Query-only:
?tab=settings - Empty string:
"" javascript:URLs
Expression href validation
Expression href attributes (href={ expr }) are parsed as Go AST and validated:
hrefpackage calls (href.PageXxx(),href.External(...),href.Asset(...)) are always allowed. Forhref.External, the first argument is checked if it is a string literal or constant — if it resolves to a disallowed URL, an error is reported.- Any other function call (e.g.
templ.SafeURL(...),fmt.Sprintf(...),loginHref()) is rejected because the result cannot be statically verified. - String literals and constants are resolved and checked against the allowed/disallowed rules above.
- Bare identifiers are resolved via
constvalues. Qualified identifiers (e.g.urls.LoginURL) are resolved via exported constants from imported packages. Variables are not trusted (their value cannot be determined statically).
Suppressing Lint Errors
Use //datapages:nolint in a templ file to suppress the next element's lint errors.
An optional trailing explanation comment is allowed:
//datapages:nolint
<a href="/legacy-path">Legacy</a>
//datapages:nolint // migrating to href package in #1234
<a href="/another-legacy">Another</a>
The directive applies to the immediately following non-whitespace sibling element.
It suppresses all attribute-level lint errors (hardcoded href, unverifiable href,
href.External with internal URL, hardcoded action, unverifiable action,
form action, action/href context mismatch) — it does not suppress
cross-page action ownership errors.
Technical Limitations
-
For now, with CSRF protection enabled, you will not be able to use plain HTML forms, since the CSRF token is auto-injected for Datastar
fetchrequests (whereDatastar-Requestheader istrue). You must use Datastar actions for any sort of server interactivity. -
The href linter cannot detect absolute links to your own domain (e.g.
href="https://mydomain.com/login"). These bypass the linter because they have an explicit URL scheme, which the linter treats as external. Use the generatedhref.PageXxx()builders instead.