| name | datapages |
| description | Write Datapages application source packages and use the Datapages CLI. Activate when the user wants to build a web app with Datapages, when you see a datapages.yaml file, or when the project imports the datapages module. |
Writing a Datapages Application
You write Go application logic and templates. Datapages generates the server.
Follow these steps in order. Do not skip steps.
For the full specification of all parameters, return types, supported field types, and
configuration options, see SPECIFICATION.md.
Datapages apps use two other technologies. This skill does not teach them.
Learn them separately.
- Templ (
github.com/a-h/templ) - Go HTML templating.
Handlers return templ.Component. You write .templ files that compile to Go
via templ generate. Datapages does not run this automatically — you must
run templ generate yourself after creating or modifying .templ files.
Docs: https://templ.guide/developer-tools/llm/
- Datastar (
github.com/starfederation/datastar-go/datastar) - Frontend
reactivity via HTML attributes and SSE. Actions use data-on-click and
data-action attributes in templates. Docs: https://data-star.dev
See also: datastar/SKILL.md
Architecture
- Hypermedia-First (MPA): This is a multi-page application architecture. The backend drives the UI by sending HTML fragments, signal updates, and real-time events over SSE. There is no separate REST API layer - all interactions happen through Datastar SSE streams managed by Datapages.
- Morphing & Patching: Datastar uses morphing to update the DOM - it compares the incoming HTML fragment with the existing DOM and applies minimal changes, preserving focus, scroll position, and CSS transitions. Prefer HTML fragment patches (morphs) over signal updates because morphs carry both structure and data, keeping the server as the single source of truth. Use signal updates only for lightweight, transient UI state (e.g. toggling a loading spinner, updating a counter) where re-rendering HTML would be wasteful. "Fat morphs" - sending a larger HTML fragment that includes surrounding context - are often simpler and more robust than trying to surgically update individual elements. Often a single template per page that renders the entire body is the best approach because it reduces complexity and avoids coordinating multiple partial updates.
- Backend Reactivity: The server renders HTML and manages application state. The frontend is a thin reactive layer that responds to backend updates. The backend determines what the user can do by controlling DOM patches, maintaining a single source of truth.
- Simplicity First: Keep Datastar expressions simple - complex logic belongs in backend handlers or external scripts. Use a "props down, events up" pattern: pass data into functions via arguments, return results or dispatch custom events. State that makes sense to keep on the client (e.g. UI toggles, form input) should be realized using client-side signals, state that should be persisted or shared should live on the server, and state necessary for actions should be communicated via signals.
- No JavaScript: Avoid solving problems with JavaScript. All application logic belongs in Go on the server. Datastar's declarative attributes (
data-on, data-bind, data-show, etc.) and signal expressions handle the frontend. Only reach for JavaScript when there is genuinely no other way — e.g. clipboard access, focus management, or interfacing with a third-party browser API that Datastar cannot express.
Step 1: Initialize
Run this:
datapages init --non-interactive --name myapp --module github.com/user/myapp
Prometheus metrics generation is enabled by default.
Use --prometheus=false to disable it.
It creates app/app.go, app/app.templ, datapages.yaml, .env, compose.yaml,
Makefile, and cmd/server/main.go.
If the project already has datapages.yaml, skip this step.
datapages.yaml Structure
The generated config looks like this:
app: app
gen:
package: datapagesgen
prometheus: true
cmd: cmd/server
assets:
url-prefix: /static/
dir: ./app/static/
watch:
exclude:
- ".git/**"
- ".*"
- "*~"
The fields agents are most likely to change (usually not necessary):
app — path to the app source package (default app)
gen.package — where generated code goes (default datapagesgen)
gen.prometheus — set false to disable metrics generation
cmd — path to the server command package (default cmd/server)
assets — static asset serving config (optional). When omitted, asset serving is disabled. When set, both sub-fields are required:
assets.url-prefix — URL path prefix; must start and end with /. Emitted as the URLPrefix constant in the generated assets subpackage.
assets.dir — on-disk path to the static files directory (e.g. ./app/static/). Used for embed.FS subdirectory derivation and dev-mode disk serving.
Step 2: Define Minimal App
Open app/app.go. The package name is app.
Write the App struct. Add all your dependencies to it.
package app
import (
"net/http"
"github.com/a-h/templ"
)
type App struct{}
type PageIndex struct{ App *App }
func (PageIndex) GET(r *http.Request) (body templ.Component, err error) {
return indexPage(), nil
}
Important:
- The doc comment says
// PageIndex is /. The word is matters. The route follows.
- The struct has
App *App. Every page needs this field. No exceptions.
- The GET method uses a value receiver. Not a pointer.
- Datapages rejects the package without an
App or PageIndex struct types.
Step 3: Add Session (Optional)
Define a Session struct if you need authentication, otherwise don't define it.
type Session struct {
UserID string
IssuedAt time.Time
}
Both fields are required. Without either, the parser rejects it.
Add custom fields if you want. Custom fields must have json tags.
Now handlers can accept session Session or sessionToken string as parameters, and return newSession Session or closeSession bool as return values.
Step 4: Add Pages
One struct per page. One doc comment per page. One GET handler per page.
type PageLogin struct{ App *App }
func (PageLogin) GET(r *http.Request) (body templ.Component, err error) {
return loginPage(), nil
}
Page names: Page then an uppercase letter then letters and digits.
PageLogin works. Pagelogin does not. Page_Login does not.
No underscores. No lowercase after Page.
Routes use Go standard library net/http.ServeMux pattern syntax.
/item/{id} captures a path segment. /{path...} captures the rest.
See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for the full spec.
GET Return Values
The minimum is (body templ.Component, err error).
You can add more. Pick only what you need.
body templ.Component
head templ.Component
redirect string
redirectStatus int
newSession Session
closeSession bool
enableBackgroundStreaming bool
disableRefreshAfterHidden bool
err error
Examples:
(body, head templ.Component, err error)
(body templ.Component, redirect string, err error)
(body templ.Component, newSession Session, disableRefreshAfterHidden bool, err error)
Step 5: Path Variables and Query Parameters
These work in both GET handlers and action handlers.
Path Variables
Put them in the route. Read them in the handler.
type PageItem struct{ App *App }
func (PageItem) GET(
r *http.Request,
path struct {
ID string `path:"id"`
},
) (body templ.Component, err error) {
return itemPage(path.ID), nil
}
The tag path:"id" must exactly match {id} in the route.
Query Parameters
func (PageSearch) GET(
r *http.Request,
query struct {
Term string `query:"t"`
Limit int `query:"l"`
},
) (body templ.Component, err error) {
return searchPage(query.Term, query.Limit), nil
}
The query tag specifies the query parameter name. query:"t" maps to ?t=... in the URL. See SPECIFICATION.md for all supported field types.
Step 6: Add Actions
Actions handle POST, PUT, PATCH, or DELETE. They are methods on page types similar to GET.
Give each one a doc comment with a route.
func (PageLogin) POSTSubmit(r *http.Request) error {
return nil
}
Action names: POST then uppercase letter then letters and digits.
Same for PUT, PATCH, and DELETE.
POSTSubmit works. POSTsubmit does not. POST_Submit does not.
No underscores. No lowercase after POST/PUT/PATCH/DELETE.
Actions can also be defined on *App (pointer receiver) for global actions not tied to a specific page:
func (*App) POSTSignOut(r *http.Request, session Session) (
closeSession bool,
redirect string,
err error,
) {
return true, "/login", nil
}
Action Parameters
Parameters may be in any order. Skip what you don't need.
r *http.Request
sse *datastar.ServerSentEventGenerator
sessionToken string
session Session
path struct { ID string `path:"id"` }
query struct { P int `query:"p"` }
signals struct { V string `json:"v"` }
dispatch func(EventFoo) error
Import "github.com/starfederation/datastar-go/datastar" for SSE.
Action Return Types
Pick only what you need.
body templ.Component
head templ.Component
redirect string
redirectStatus int
newSession Session
closeSession bool
err error
Examples:
Simple:
) error {
Redirect:
) (redirect string, redirectStatus int, err error) {
return "/", 303, nil
}
New session:
) (newSession Session, redirect string, err error) {
return Session{UserID: "u1", IssuedAt: time.Now()}, "/", nil
}
Close session:
) (closeSession bool, redirect string, err error) {
return true, "/login", nil
}
HTTP error status (use the generated datapagesgen/httperr sentinels):
return httperr.BadRequest
return httperr.Forbidden
return fmt.Errorf("%w: %w", httperr.NotFound, errOriginal)
Errors without a sentinel default to 500 (or RecoverError if defined).
Step 7: Add Signals
Signals are Datastar frontend state. Inline struct with json tags.
func (PageForm) POSTSubmit(
r *http.Request,
signals struct {
Name string `json:"name"`
Email string `json:"email"`
},
) error {
return nil
}
Add reflectsignal to a query field to bind it to a Datastar signal. The query parameter initializes the signal value on page load, and when the signal changes, the browser URL is updated to reflect the new value:
func (PageSearch) GET(
r *http.Request,
query struct {
Term string `query:"t" reflectsignal:"term"`
},
signals struct {
Term string `json:"term"`
},
) (body templ.Component, err error) {
return searchPage(query.Term), nil
}
Step 8: Add Events
Events push real-time updates over SSE.
Each event is defined by a type in the app source package.
Define the type. Write the doc comment with a quoted subject.
type EventMessageSent struct {
Message string `json:"message"`
}
Event names: Event then uppercase letter then letters and digits.
The subject is quoted. "messaging.sent" works. messaging.sent does not.
Dispatch from Actions
Add dispatch as the last parameter.
func (PageChat) POSTSend(
r *http.Request,
dispatch func(EventMessageSent) error,
) error {
return dispatch(EventMessageSent{Message: "hello"})
}
Multiple events:
dispatch func(EventMessageSent, EventUserActive) error
Handle Events on Pages
Method name starts with On. The event and sse parameters are required. Optional parameters: streamID uint64, session Session, sessionToken string.
Parameters may appear in any order.
On handlers do not accept signals. If the handler needs client-side signal values, add them as fields on the event type and populate them in the action handler that dispatches the event.
Use streamID to look up per-tab state registered in StreamOpen (see Step 9).
func (PageChat) OnMessageSent(
event EventMessageSent,
sse *datastar.ServerSentEventGenerator,
streamID uint64,
session Session,
sessionToken string,
) error {
return sse.PatchElementTempl(messageComponent(event.Message))
}
Subject Fields
Any field named Subject<Name> with type string or []string is a subject field. Subject fields must appear before any payload fields.
The values of all subject fields are combined as a Cartesian product and appended to the event's base NATS subject. For example, if an event has SubjectUser with values ["u1", "u2"], the base subject messaging.sent becomes messaging.sent.u1 and messaging.sent.u2.
SubjectUser is special: its presence makes the event private (requires a Session type with Session.UserID to match connected users).
type EventDirectMessage struct {
SubjectUser []string `json:"subject_user"`
Content string `json:"content"`
}
Step 9: Add Stream Hooks (Optional)
StreamOpen and StreamClose run when a page's SSE stream opens and closes.
When to use stream hooks
- Per-tab server-side state. Store filters, sort order, or view preferences
keyed by
streamID. Event handlers (which don't receive signals) can then look
up the current tab's state to render correctly filtered responses. Clean up in
StreamClose.
- CQRS read-model binding. In a CQRS architecture, actions (commands) dispatch
events and event handlers (queries) render the updated UI. The event handler
needs context about which tab it is rendering for (e.g. which item is being
viewed, which filters are active).
StreamOpen is the place to capture that
context — read initial signals, store them alongside the streamID, and use them
later in event handlers to produce the right fat morph.
- HMAC-signed tab identifiers. Generate an HMAC of the
streamID in
StreamOpen, patch it to the client as a signal via sse.MarshalAndPatchSignals,
and validate it in action handlers. Because only the server knows the HMAC key,
clients cannot forge a tab ID for a stream they don't own, which prevents
one tab from impersonating another when calling actions.
- Resource lifecycle. Acquire per-stream resources (subscriptions, connections,
counters) in
StreamOpen and release them in StreamClose.
Signature
Both require r *http.Request and streamID uint64. They return only error.
The streamID is a per-process unique identifier for the SSE stream instance.
Use it to correlate open and close for the same stream.
It is intended for internal server-side bookkeeping only and must not be exposed
to clients, as it could leak information about server activity and connection volume.
StreamOpen runs after the SSE stream is established, before any event handler.
It additionally accepts these optional parameters:
sse *datastar.ServerSentEventGenerator, sessionToken string, session Session,
signals struct{...}, dispatch func(...) error.
StreamClose runs when the stream closes.
It additionally accepts these optional parameters: sessionToken string,
session Session, dispatch func(...) error.
Note: StreamClose does not accept sse or signals.
func (PageIndex) StreamOpen(
r *http.Request,
streamID uint64,
sse *datastar.ServerSentEventGenerator,
sessionToken string,
session Session,
signals struct{
Instance string `json:"instance"`
},
dispatch func(EventPing) error,
) error {
return nil
}
func (PageIndex) StreamClose(
r *http.Request,
streamID uint64,
sessionToken string,
session Session,
dispatch func(EventPing) error,
) error {
return nil
}
Stream hooks can also be defined on abstract types and embedded in pages,
following the same pattern as event handlers (see next step).
Step 10: Share Handlers Across Pages
When multiple pages need the same event handler or action, define it once on an abstract type and embed it. This avoids duplicating handler methods across pages.
Abstract types are not pages. No Page prefix. No route.
type Base struct{ App *App }
func (Base) OnMessageSent(
event EventMessageSent,
sse *datastar.ServerSentEventGenerator,
) error {
return sse.PatchElementTempl(notificationComponent())
}
Embed them in pages:
type PageChat struct {
App *App
Base
}
Every page that embeds Base automatically gets OnMessageSent without repeating the code.
To override, redefine the method on the page - the page-level method replaces the embedded one entirely for that page, while other pages that embed Base keep the original. You can also wrap the embedded method by calling it from the override:
func (p PageChat) OnMessageSent(
event EventMessageSent,
sse *datastar.ServerSentEventGenerator,
) error {
log.Println("chat-specific handling")
return p.Base.OnMessageSent(event, sse)
}
Step 11: Add Custom Error Pages (Optional)
Without these, Datapages serves default error responses. Define custom error pages to match your app's look and feel and provide helpful navigation back to valid pages.
type PageError404 struct{ App *App }
func (PageError404) GET(r *http.Request) (body templ.Component, err error) {
return notFoundPage(), nil
}
Same pattern for PageError500.
Step 12: Add Global Head (Optional)
Adds shared <head> content (meta tags, stylesheets, scripts) to every page, so you don't have to repeat it in each page's head return value. Pointer receiver on App.
func (*App) Head(
r *http.Request,
sessionToken string,
session Session,
) templ.Component {
return globalHead()
}
Step 13: Add Error Recovery (Optional)
When a handler returns an error during a Datastar SSE request, a plain HTTP error is invisible to the user - there is no visible feedback, only a console log that normal users never see. RecoverError lets you handle this gracefully by patching in an error UI (e.g. a toast notification) over SSE instead. All action handler errors (including httperr sentinels) are routed through RecoverError when defined. Use errors.Is(err, httperr.BadRequest) etc. inside RecoverError to distinguish error types.
func (*App) RecoverError(
err error,
sse *datastar.ServerSentEventGenerator,
) error {
return sse.PatchElementTempl(errorToast(err))
}
Step 14: Configure the Server Entry Point
datapages gen generates cmd/server/main.go on the first run. After that, you own this file - it is not regenerated or overwritten. Edit it to configure dependencies, middleware, and server options.
The generated main.go imports two key packages from your project:
import (
"your-module/app"
"your-module/datapagesgen"
)
Create the Server
datapagesgen.NewServer requires your app, a message broker, and (if you defined a Session type) a session manager:
s := datapagesgen.NewServer(a, messageBroker, opts...)
s := datapagesgen.NewServer(a, messageBroker, sessionManager, opts...)
Message Broker
A message broker is always required. It delivers events between pages and handles SSE fan-out.
Use NATS JetStream for the message broker:
import "github.com/romshark/datapages/modules/msgbroker/natsjs"
An in-memory broker (github.com/romshark/datapages/modules/msgbroker/inmem) exists but should only be used in single-instance setups that don't require persistence. Prefer NATS JetStream in most cases.
Session Manager
Required only if you defined a Session type. Use NATS KV for the session manager:
import "github.com/romshark/datapages/modules/sessmanager/natskv"
An in-memory session manager (github.com/romshark/datapages/modules/sessmanager/inmem) exists but should only be used in single-instance setups where losing sessions on restart is acceptable. Prefer NATS KV in most cases.
Server Options
Pass options to NewServer to configure middleware, CSRF protection, static files, TLS, etc.:
var opts []datapagesgen.ServerOption
opts = append(opts, datapagesgen.WithMiddleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slog.Info("access", slog.String("path", r.URL.Path))
next.ServeHTTP(w, r)
})
}))
opts = append(opts, datapagesgen.WithCSRFProtection(datapagesgen.CSRFConfig{
TokenManager: tm,
DevBypassToken: os.Getenv("CSRF_DEV_BYPASS"),
}))
opts = append(opts, datapagesgen.WithAuth(datapagesgen.AuthConfig{}))
opts = append(opts, datapagesgen.WithLogger(slog.Default()))
opts = append(opts, datapagesgen.WithHTTPServer(&http.Server{
ReadHeaderTimeout: 10 * time.Second,
}))
opts = append(opts, datapagesgen.WithDatastarJS("https://cdn.example.com/datastar.js"))
opts = append(opts, datapagesgen.WithPrometheus(datapagesgen.PrometheusConfig{
Host: ":9091",
}))
Listen and Serve
s.ListenAndServe(ctx, "localhost:8080")
s.ListenAndServeTLS(ctx, "localhost:8443", certPath, keyPath)
Step 15: Serve Static Files (Optional)
If your app needs to serve static assets (CSS, JS, images, fonts), place them in a directory inside your app package (e.g. app/static/) and use Go's embed package to bundle them into the binary.
Create an app/assets.go file:
package app
import "embed"
var StaticFS embed.FS
Then register the static filesystem in cmd/server/main.go using a server option.
This is only needed when the app serves static files (CSS, JS, images, fonts):
opts = append(opts, datapagesgen.WithAssets(app.StaticFS))
WithAssets takes an embed.FS and handles everything automatically: in production, it extracts the subdirectory (assets.Dir) and serves embedded files; in dev mode (IsDevMode), it serves from disk (assets.DevDir) with caching disabled for live reloading without recompilation.
The URL path prefix is the generated assets.URLPrefix constant (configured via assets.url-prefix in datapages.yaml). The embed.FS subdirectory and dev-mode disk path are derived from assets.dir.
Reference static files in templates using the static prefix (e.g. /static/style.css).
Step 16: Generate and Run
Build workflow after editing app.go or .templ files:
templ generate
datapages gen
go build ./cmd/server
If datapages gen reports errors, fix the Go source in app/ and re-run.
Never edit files ending in _gen.go or files containing a DO NOT EDIT header comment — they are overwritten by code generation.
CLI reference:
datapages gen
datapages lint
datapages watch
datapages version
datapages help
datapages help <command>
Step 17: Use Generated URL Packages
datapages gen produces two packages with type-safe URL builders. Always use these instead of hardcoding URLs.
datapagesgen/href — Page Links
Generated functions return URL strings for <a href> attributes. One function per page.
// Simple page
<a href={ href.Index() }>Home</a>
<a href={ href.Login() }>Log in</a>
// Page with path variable (e.g. PagePost is /post/{slug})
<a href={ href.Post(post.Slug) }>{ post.Title }</a>
// Page with query parameters
<a href={ href.Messages(href.QueryMessages{Chat: chatID}) }>Messages</a>
Query parameter structs are generated as href.Query<PageName>. Zero-value fields are omitted from the URL.
datapagesgen/action — Datastar Actions
Generated functions return Datastar action strings (@post('/...'), @put('/...'), etc.) for use in data-on:click and similar attributes. One function per action handler.
// Simple action
<button data-on:click={ action.POSTPageLoginSubmit() }>Submit</button>
// Action with path variable
<button data-on:click={ action.POSTPagePostSendMessage(slug) }>Send</button>
// Action with query parameters
<button data-on:click={ action.POSTPageMessagesRead(
action.QueryPOSTPageMessagesRead{MessageID: msg.ID},
) }>Mark Read</button>
// App-level action (not tied to a page)
<button data-on:click={ action.POSTAppSignOut() }>Sign Out</button>
// Action with Datastar options (e.g. payload, contentType, filterSignals)
<button data-on:click={ action.POSTPageLoginSubmit(
action.WithOption(action.OptPayload, "'auto'"),
) }>Submit</button>
// Action with before/after expressions (joined with "; " separators)
<button data-on:click={ action.POSTPageLoginSubmit(
action.WithBefore("$foo='asd'"),
action.WithAfter("$foo=''"),
) }>Submit</button>
All generated action functions accept variadic modifier arguments:
action.WithOption(key, value) — passes a Datastar action option. The key is an action.Opt constant (e.g. OptContentType, OptFilterSignals, OptSelector, OptHeaders, OptOpenWhenHidden, OptPayload, OptRetry, OptRetryInterval, OptRetryScaler, OptRetryMaxWaitMs, OptRetryMaxCount, OptRequestCancellation). The value is a raw JavaScript expression string (use "'auto'" for a JS string, "true" for a boolean).
action.WithBefore(expr) — prepends a JavaScript expression before the action call, separated by "; ".
action.WithAfter(expr) — appends a JavaScript expression after the action call, separated by "; ".
Naming convention: {METHOD}Page{PageName}{HandlerName} for page actions, {METHOD}App{HandlerName} for app-level actions. Query parameter structs are generated as action.Query<FunctionName>.