Application model
Datapages generates an application's HTTP server from 1 Go package. The CLI finds this app package through datapages.NewServer, reads its declarations and writes datapagesgen below it.
The app package
The app package holds the App type, pages, handlers, events and templ components. Together they form the application model. There is no route table. A page type declares its route on the first line of its doc comment.
The scaffold that datapages init writes keeps the app package in app/ and the entry point in cmd/server/:
.
|-- app/
| |-- app.go the application model
| |-- app.templ templ components
| |-- app_templ.go written by templ generate
| `-- datapagesgen/ written by datapages gen
|-- cmd/
| `-- server/
| `-- main.go dependencies, server options, datapages.NewServer
|-- datapages.yaml settings of datapages watch
`-- go.mod
How the CLI finds it
datapages gen, datapages lint and datapages watch find the app package through the datapages.NewServer call of the entry point:
s, err := datapages.NewServer[
app.App, // the application type, which names the app package
datapages.DisableSessions, // the session data type, or DisableSessions
datapages.DisablePrometheus, // EnablePrometheus or DisablePrometheus
datapagesgen.Server, // the Server type of app/datapagesgen
](a, broker, opts...)
A module without any NewServer call, such as a project whose entry point is not written yet, is generated for the package in ./app. That run also writes cmd/server/main.go with a NewServer call when the directory holds no Go files. The cmd setting of datapages.yaml names another directory. From then on the CLI reads the call in main.go. Project discovery lists the exact rules of the scan. A module can hold several app packages, each with its own entry point. See Multiple applications.
What Datapages reads
Datapages reads the names and types of the package-level declarations and the first line of their doc comments:
package app
import (
"embed"
"net/http"
"github.com/romshark/datapages"
)
// StaticFS is /static/
//
//go:embed static
var StaticFS embed.FS
type App struct{ posts *Posts }
// EventPostPublished is "post.published"
type EventPostPublished struct {
Slug string `json:"slug"`
}
// PageIndex is /
type PageIndex struct{ App *App }
func (p PageIndex) GET(r *http.Request) (body datapages.Component, err error) {
return pageIndex(p.App.posts.Published()), nil
}
func (p PageIndex) OnPostPublished(
event EventPostPublished, sse datapages.SSE,
) error {
return sse.PatchElement(postList(p.App.posts.Published()))
}
// PagePost is /post/{slug}
//
// Shows 1 post with its comments.
type PagePost struct{ App *App }
func (p PagePost) GET(
r *http.Request,
path datapages.Path[struct {
Slug string `path:"slug"`
}],
) (body datapages.Component, err error) {
post, ok := p.App.posts.Get(path.Values.Slug)
if !ok {
return nil, datapages.ErrNotFound
}
return pagePost(post), nil
}
// POSTPublish is /publish/{slug}
func (a *App) POSTPublish(
r *http.Request,
path datapages.Path[struct {
Slug string `path:"slug"`
}],
published datapages.Dispatcher[EventPostPublished],
) error {
if !a.posts.Publish(path.Values.Slug) {
return datapages.ErrNotFound
}
return published.Dispatch(EventPostPublished{Slug: path.Values.Slug})
}
Posts is an ordinary type in another file of the package, and pageIndex, postList and pagePost are templ components in app/app.templ. Datapages ignores declarations it does not recognize. From the declarations above it generates:
| Declaration | Recognized by | Generated |
|---|---|---|
App |
the name App, which is required |
the value every handler reaches through p.App |
PageIndex |
the Page prefix, the field App *App and the route comment |
the route GET / and href.PageIndex() |
PageIndex.OnPostPublished |
the On prefix and a parameter of type EventPostPublished |
the page stream GET /_$/{$}, subscribed to post.published |
PagePost |
the same as PageIndex |
the route GET /post/{slug}/{$} and href.PagePost(slug) |
(*App).POSTPublish |
the POST prefix and the route comment |
the route POST /publish/{slug}/{$} and action.App.Publish.POST(slug) |
EventPostPublished |
the Event prefix and the quoted subject in its comment |
the subject post.published and the dispatcher behind datapages.Dispatcher[EventPostPublished] |
StaticFS |
an embed.FS variable whose comment names a URL path |
the file server at /static/, the assets package and href.Asset |
Declarations
These package-level declarations have a meaning:
App, a type that must exist. The handlers reach it through theApp *Appfield of their page. Put long-lived dependencies, such as repositories and API clients, on it.- Pages, struct types named
Pagefollowed by an uppercase letter. A page declares exactly 1 named field,App *App, and aGETmethod. It may also embed stream embeds.PageIndexis required and serves/. Its route also catches every path that no other route matches, which it answers with the 404 page. - Special pages:
PageError404andPageError500replace the default error pages, andPageOfflineis the page the offline worker shows for an uncached URL. Each declares its route like any other page. - Stream embeds, structs whose name does not start with
Pageand that have the fieldApp *App. A page embeds them to share event handlers,StreamOpenandStreamClosewith other pages. - Events, types named
Eventfollowed by an uppercase letter, with the comment// EventXXX is "subject". An event type may also come from an imported package. See Events. - Per-tab state, an exported struct of the app package used as
Tindatapages.State[T]. See Per-tab state. - The session data type, the
Dataofdatapages.Session[Data]ordatapages.NewSession[Data]in a handler. Every handler uses the sameData. See Sessions and auth. - Assets, at most 1
embed.FSvariable whose comment names a URL path, as// StaticFS is /static/. See Static assets.
A page that has an event handler, a stream hook or per-tab state gets an SSE stream at its route followed by _$/. Request lifecycle shows how a page uses it.
Methods
The prefix of a method name decides its role:
| Method | Owner | Role |
|---|---|---|
GET |
page | renders the page |
GETXXX |
page, *App |
answers GET with a datapages.File |
POSTXXX, PUTXXX, PATCHXXX, DELETEXXX, QUERYXXX |
page, *App |
an action called from the browser |
OnXXX |
page, stream embed | handles EventXXX in the open tabs of the page |
StreamOpen, StreamClose |
page, stream embed | run when the page stream opens and closes |
StreamConfig |
page | configures the compression of the page stream |
StreamConfigXXX |
page, *App |
configures the SSE response of the action XXX |
Head |
*App |
renders the <head> tags of every page |
RecoverError |
*App |
renders the error of a failed Datastar request |
Declare page methods on a value receiver and App methods on *App. An unexported method is an ordinary method. An exported page method with any other name is an error. App may declare other exported methods whose names start with none of these prefixes, and Datapages ignores them.
Routes
The first line of the doc comment is a net/http ServeMux pattern, as in // PagePost is /post/{slug}. Put a description after an empty // line. Datapages registers the route with a trailing slash and {$}, as in GET /post/{slug}/{$}. The PageIndex route / and routes ending in {name...} keep their original form. The server appends a trailing slash to every other request path outside the asset prefix. /post/hello and /post/hello/ therefore reach the same page.
The route of a page action starts with the route of its page. POSTComment on PagePost can be /post/{slug}/comment but not /comment. An action on App can have any route. Each route variable, such as {slug}, needs a field in the handler's datapages.Path parameter. See Pages and routes.
Parameters and return values
The generator matches handler parameters and return values by type, not by name or position. A handler declares only the values it needs. This action takes them in an unusual order, and the generated code passes each by its type:
// POSTComment is /post/{slug}/comment
func (p PagePost) POSTComment(
signals datapages.Signals[struct {
Text string `json:"text"`
}],
sse datapages.SSE,
path datapages.Path[struct {
Slug string `path:"slug"`
}],
r *http.Request,
) error {
post, ok := p.App.posts.AddComment(path.Values.Slug, signals.Values.Text)
if !ok {
return datapages.ErrNotFound
}
return sse.PatchElement(comments(post))
}
The only name with a meaning is stateID string, which a handler with per-tab state takes for the ID of its tab. A parameter of a type the handler kind does not accept is an error. The specification lists the parameters and return values of every handler kind.
Naming rules
datapages gen and datapages lint reject a declaration that claims a role but breaks its naming rule:
- After the prefix
Page,On,GET,POST,PUT,PATCH,DELETEorQUERYcomes an uppercase letter, then only letters and digits.Page_HelpandPOSTsaveare rejected. A method whose name starts with a lowercase letter, aspostSave, is an ordinary method. - Every type whose name starts with
Pageis read as a page.type PageSize intis rejected withpage type must be a struct type. Give such types other names. - A type is an event only when its name is
Eventfollowed by an uppercase letter and letters and digits.Event_Logis an ordinary type. OnXXXrepeats the name of its event afterEvent, asOnPostPublishedforEventPostPublished, also for an event declared in another package.- The comment line uses the word
is. A route is unquoted, as// PagePost is /post/{slug}. An event subject is quoted, as// EventPostPublished is "post.published". - A page type is a struct type literal whose only named field is
App *App. App, page, stream embed and event types have no type parameters.- The app package has no build-constrained files, such as a file with a
//go:buildline or a name ending in_windows.go, except tests and files under//go:build ignore.
The parser also rejects routes that conflict, an action route outside its page's route, a route character that must be percent-encoded, such as ? or #, and 2 event types with the same subject. Linting lists the template checks that run in the same pass.
What datapages gen writes
datapages gen rewrites everything below datapagesgen/ on each run. The todolist example shows a typical result:
example/todolist/app/datapagesgen/
|-- app_gen.go
|-- action/
| `-- action_gen.go
|-- assets/
| `-- assets_gen.go
`-- href/
`-- href_gen.go
app_gen.go, packagedatapagesgen: theServertype and itsInitmethod, whichdatapages.NewServercalls. It registers the routes and holds the handler of every page, page stream and action, the dispatcher of every event, the subject constants andMessageBrokerStreamSubjects, and the store of per-tab state. An app withPageOfflinealso getsWithOfflinehere.href/href_gen.go, packagehref: 1 function per page that returns its URL, such ashref.PagePost(slug), and 1 per GET action.PageError500has none. A query struct of a page becomes a type such ashref.QueryPageIndex.href.Externalmarks an external URL. An app package with assets also getshref.Asset.action/action_gen.go, packageaction: 1 builder per action that returns the Datastar expression calling it, such asaction.App.Publish.POST(slug), which returns@post('/publish/hello/'). A GET action has none. The options of the expression, such asaction.WithContentType, are here too.assets/assets_gen.go, packageassets, only for an app package that declares assets: the URL prefix, the embedded directory, the directory dev mode reads from, andassets.Path.
A run also does the following:
- It parses and checks every app package of the module before it writes any of them. An app package with errors keeps the code an earlier run generated for it, and the others are generated. An app package that was never generated gets stub packages, which let editors resolve its imports while you fix the errors.
- It refuses to run when
go.modrequires a newer Datapages than the CLI, and it raises the requirement to the CLI's version when the CLI is newer. - It runs
go mod tidy. - It prints a notice when the agent instructions of the project came from another CLI version.
datapages gen does not run templ generate.
What you write and what is generated
| File | Written by | Edit it |
|---|---|---|
app/*.go |
you | yes |
app/*.templ |
you | yes |
app/*_templ.go |
templ generate |
no, edit the .templ file |
app/datapagesgen/** |
datapages gen, on every run |
no, change the app package and regenerate |
cmd/server/main.go |
datapages init or the first datapages gen, when missing |
yes, it is yours after the first run |
datapages.yaml |
datapages init or datapages watch, when missing |
yes |
.env, compose.yaml, Makefile, .github/workflows/ci.yml |
datapages init, when missing |
yes |
AGENTS.md, CLAUDE.md, GEMINI.md, .agents/skills/, .claude/skills/ and the other agent files |
datapages init, on every run |
edits are replaced and kept in a .bak file, see AI agents |
go.mod, go.sum |
you, go mod tidy |
yes |
Every generated Go file starts with // Code generated by github.com/romshark/datapages; DO NOT EDIT. The scan treats a datapagesgen directory holding Go files without that header as a package Datapages did not write, and refuses to generate into it.
The build loop
Generate the server from the application model, then the templates, then build:
datapages gen # parse the app package, write app/datapagesgen
templ generate # compile app/*.templ into app/*_templ.go
go build ./...
The 2 generators depend on each other. datapages gen type-checks the app package, including the *_templ.go files, and refuses a package that does not compile. Running it first lets the templates call the href and action helpers of new declarations. 2 cases need another order:
- Go code calls a templ component that
templ generatehas not compiled yet.datapages genthen reports it as undefined, asundefined: pageSearch. Runtempl generatefirst. - A new template calls a helper of a new declaration, and Go code calls that template. Neither order works. Add the declaration and the template without the helper call, run both generators, then add the call and run
templ generateagain.
Troubleshooting shows the error and the recovery.
datapages watch
datapages watch runs the build loop, restarts the server and reloads open tabs. Its development proxy shows build errors. A second proxy simulates slow or failing networks. The server uses dev mode, which reads assets from disk and applies .templ text changes without a rebuild.
Do not run templ generate while datapages watch runs. It deletes the temporary files that templ watch mode renders from, and the dev server serves blank pages until the next change to a .templ file. See Troubleshooting.
datapages lint
datapages lint applies the checks of datapages gen and writes nothing. The checks include the .templ files of the app package and of the module packages it imports, for hardcoded URLs and for action calls in the wrong place. Run it in CI and in editors. See Linting and CLI.
Errors and fix suggestions
datapages gen and datapages lint print 1 entry per error with its position and message. When the CLI can derive a fix, a fix: line follows. This file has 3 mistakes. PageAbout has no route comment, PageSearch.GET takes a bare struct for its query, and OnUpdate does not name its event:
package app
import (
"net/http"
"github.com/romshark/datapages"
)
// EventCounterUpdated is "counter.updated"
type EventCounterUpdated struct{}
// The about page.
type PageAbout struct{ App *App }
func (PageAbout) GET(r *http.Request) (body datapages.Component, err error) {
return nil, nil
}
// PageSearch is /search
type PageSearch struct{ App *App }
func (PageSearch) GET(
r *http.Request,
query struct {
Term string `query:"q"`
},
) (body datapages.Component, err error) {
return nil, nil
}
func (PageSearch) OnUpdate(event EventCounterUpdated, sse datapages.SSE) error {
return nil
}
datapages gen prints:
at mistakes.go:13:6: page is missing path comment: PageAbout
fix: Add `// PageAbout is /about/`
at mistakes.go:24:2: unsupported input parameter query struct{Term string "query:\"q\""} in PageSearch.GET
fix: Potential candidates: datapages.Path[...], datapages.Query[...], datapages.Signals[...]
at mistakes.go:31:19: event handler must be named after its event: PageSearch.OnUpdate handles EventCounterUpdated
fix: Rename PageSearch.OnUpdate to OnCounterUpdated
error: parsing app package: 3 error(s)
Positions are relative to the directory of the app package. For a parameter of an unsupported type, the fix: line lists the parameter types the value could be wrapped in that the handler has not taken yet. The command exits with status 1 and leaves the generated code as the last successful run wrote it. NO_COLOR turns the colors of a terminal off, and FORCE_COLOR turns them on in a pipe.
A package that does not compile reports the compiler errors and nothing else, since the model cannot be read without types:
at app_templ.go:39:80: undefined: action.PageIndex
error: parsing app package: 1 error(s)
See also
- Source Package: every declaration, parameter and return value with its exact rules.
- Request lifecycle: what the generated server does with a page load, a stream, an action and an event.
- Pages and routes and Actions: writing pages and actions.
- Multiple applications: several app packages in 1 module.
- CLI: the flags of
datapages gen,datapages lintanddatapages watch. - Troubleshooting: generator errors and their fixes.