Pages

Server-rendered pages from a pb_pages folder, next to your pb_data. Each file becomes a route: EJS, Markdown, JSX and TSX templates, layouts, middleware and API routes, plus static files. Page code runs in your app's JS VM, so it can use your data directly. This is PocketPages v1, formerly the npm PocketPages package.

Install · Configure · Use · Integrations · Example

Install

Pages is built into pocketone and on whenever a pb_pages folder exists next to pb_data. There's no switch: no folder, no pages. If the folder appears while the app runs, it starts serving it.

In your own Go build, register it after the JS VM, since page code runs there:

app := pocketbase.New()
jsvm.MustRegister(app, jsvm.Config{})   // page code runs in the app's JS VM
pages.MustRegister(app, pages.Config{}) // serves pb_pages next to pb_data
pages.RegisterCommand(app.RootCmd)      // the `pages create` and `pages starters` commands
app.Start()

The module is github.com/pockethost/pocketone/ext/pages. See example/main.go and Create your own extension.

Configure

Where the folder is

By default the folder is pb_pages next to pb_data. To use another one:

pocketone serve --pagesDir /path/to/pb_pages

A relative path resolves against the working directory, like --hooksDir. Pages has no pocketone.yaml section.

+config.js

Settings for your pages live in the folder itself, in pb_pages/+config.js:

module.exports = {
  plugins: ['auth', 'realtime', require('./_private/datastar.js')],
  debug: false,
}
Key Default What it does
plugins [] Built-in plugins by name (auth, realtime), or JS plugins such as the add-ons. EJS and Markdown are always on.
debug false Detailed error pages and ctx.log.debug output even outside dev mode.

+config.js runs on each page request. Unknown keys, unknown built-in names or a plugin listed twice make every page answer with an error, and the reason goes to the log.

Dev mode

When the app runs in dev mode (--dev, or app.dev in pocketone.yaml), or +config.js sets debug: true, you get detailed error pages with the file and line, and ctx.log.debug output. Otherwise visitors see a plain "Something went wrong" page and the full error goes to the log.

Use

Start from a starter kit

pocketone pages starters                         # list the kits
pocketone pages create myapp --starter daisyui-docs
cd myapp
pocketone serve --dir ./pb_data

Then open http://127.0.0.1:8090. pages create makes a new folder (it must not exist yet) with pb_pages and anything the kit needs beside it, such as pb_migrations. Without --starter you get minimal.

Kit What it is
minimal One page, no layout, no build step (the default).
mvp One page styled with MVP.css.
htmx A realtime chat and counter with htmx and the realtime plugin.
daisyui A navbar and drawer layout with Tailwind CSS and DaisyUI.
daisyui-docs A docs site from Markdown pages.
datastar A realtime chat with rooms, with Datastar.
auth Sign-up, sign-in (password, OTP, OAuth2, anonymous), email flows and a protected account page.

Write pages

Add or edit files in pb_pages and reload the browser; no restart is needed. A few examples of what the folder holds:

pb_pages/
  +config.js         settings (above)
  +layout.ejs        wraps every page
  +error.ejs         your error page
  index.ejs          /
  about.md           /about
  blog/[slug].ejs    /blog/anything
  feeds/rss/+api.js  /feeds/rss, a route without a template
  _private/          partials, never served
  assets/app.css     a static file

The full contract is in SPEC.md: routes and precedence, middleware, +page.js and +api.js, EJS, partials, slots and layouts, Markdown, JSX and TSX, the ctx object, errors, the built-in auth and realtime plugins, JS plugins, add-ons and the changes from 0.x.

Highlights:

  • Static files get ETags and fingerprinted URLs through asset().
  • ctx.pb() is the bundled PocketBase JS SDK, called in-process as the visitor.
  • ctx.res.sse() streams server-sent events.
  • The auth plugin gives cookie sessions on PocketBase auth: password, OTP, anonymous, register, OAuth2 and the email flows (verification, password reset, email change). Accounts that need MFA can't sign in through pages yet.

What you'll see in the log

Problems in the folder (a bad file name, two files claiming the same route) are logged as pages: ... errors. If the site served cleanly before, pages keeps the previous routes until the folder builds cleanly again. Errors thrown by page code are logged in full.

Try the demo

example/ is a demo app with EJS, Markdown, JSX, auth, ctx.pb(), SSE and a JSON route. It has a live speed check at /speed.

cd example && go run . serve --dir=./pb_data

Integrations

Works with

Extension How they fit
Pocker pockerext serves each instance's own pb_pages (below).
OAuth Server Renders its sign-in screens from pb_pages/_private/oauth/ when you have them there (pocketone oauth eject writes them).
Deploy pb_pages deploys with your other pb_* folders. pull doesn't fetch it by default; name it: pocketone pull prod pb_pages.
JS Errors Both run in the same JS VM; JS Errors covers pb_hooks, pages report their own file and line.

Under pocker

pockerext.New(pages.Config{}) serves every instance's <instance dir>/pb_pages:

srv := &pocker.Server{Config: cfg, Extensions: []pocker.Extension{pockerext.New(pages.Config{})}}
  • The stock pocketone binary doesn't add it in multi-instance mode yet; a custom build does, as above.
  • Each start of an instance's app gets its own route table, compiled templates and caches. An instance that sleeps drops them; nothing is shared between instances.
  • Page code runs in the instance's own sandboxed JS runtimes, under its limits (execution time, single allocations, call stack). include, require, ctx.resolve, ctx.res.file and asset never reach outside the instance's pb_pages; $os file access stays inside the instance folder. Tests: pockerext_test.go.
  • An instance turns pages off in its settings with extensions: { pages: { enabled: false } }.
  • It's its own package (pockerext), so single-app builds don't link pocker.

Measured (same machine as Speed, docs kit): each awake instance adds about 2.3 MB (50 instances: 41 MB idle to 155 MB). One instance under pocker serves about 4,000 requests/s (p50 5.6 ms, p99 80 ms at 50 concurrent): less than a single app because pocker keeps 2 prewarmed JS runtimes per instance (hooks_pool_size; a single app has 15) and every call goes through the sandbox.

To try it: copy sites into pockerext/example/pb_root/<name>.localhost/pb_pages, run go run ./pockerext/example, and open http://<name>.localhost:8791.

Building on pages

  • Render a template from Go. pages.For(app).RenderTemplate(e, "oauth/signin", status, data) renders pb_pages/_private/oauth/signin.ejs (or .md, .jsx, .tsx) as the whole response, with data as ctx.data and no middleware, layouts or +page.js, for any method. It returns handled=false when the project has no such template. OAuth Server uses it.
  • Read the routes. pages.For(app) returns the service: Dir() is the folder, Table() the current route table, Errors() the problems from the last build.
  • JS plugins. A factory in +config.js can add to ctx, run on each request and transform rendered HTML (SPEC.md, section 9.2). Add-ons are built this way.

Example

example/main.go serves the demo in example/pb_pages: EJS, Markdown, JSX/TSX, auth, SSE and a JSON route. Run go run . serve --dir=./pb_data in example/ and open http://127.0.0.1:8090.

// Command example runs one PocketOne app that serves the demo in ./pb_pages (EJS, Markdown, JSX/TSX, auth,
// ctx.pb(), SSE, a JSON route, +404). ./pb_migrations creates a "notes" collection
// with three seed notes. From this folder:
//
//	go run . serve --dir=./pb_data
//
// Then open http://127.0.0.1:8090. Edit anything in pb_pages and reload; no restart needed.
//
// Logins (ext/devusers, local testing only): dashboard [email protected] / password, app user [email protected] /
// password.
package main

import (
	"log"

	"ganigeorgiev.com/pocketbase/pocketbase"
	"ganigeorgiev.com/pocketbase/pocketbase/plugins/jsvm"
	"github.com/pockethost/pocketone/ext/devusers"
	"github.com/pockethost/pocketone/ext/pages"
)

func main() {
	app := pocketbase.New()
	devusers.Register(app) // local testing: superuser [email protected] and user [email protected], password "password"

	jsvm.MustRegister(app, jsvm.Config{}) // page code runs in the app's JS VM
	pages.MustRegister(app, pages.Config{})

	if err := app.Start(); err != nil {
		log.Fatal(err)
	}
}

Speed

ext/pages against npm PocketPages 0.22.5 (the last 0.x release), serving the same starter kit pages: the 0.x kits in pb_hooks/pages on stock PocketBase 0.41.1, the v1 ports in pb_pages on the PocketOne binary.

Page Version Requests/s (50 concurrent) p50 p99 One at a time (median)
minimal / (EJS) npm 0.22.5 620 30 ms 670 ms 5.7 ms
ext/pages 10,850 1.8 ms 37 ms 0.2 ms
daisyui-docs /docs/overview (layouts, Markdown, partial) npm 0.22.5 580 28 ms 1.1 s 6.5 ms
ext/pages 6,510 4.1 ms 49 ms 0.4 ms
PocketBase's own /api/health (the ceiling) PocketOne 55,000 0.9 ms 2.4 ms 0.1 ms

About 17.5× more requests per second on the simple page and 11× on the docs page, with p99 latency 20× lower. Single requests take 0.2 to 0.4 ms instead of 6 ms. Memory after each run was similar (170 to 350 MB for both).

Why: 0.x parsed routes, ran EJS through a JS implementation and loaded its plugins inside the JS VM on every request. v1 builds the route table once, compiles each template once (EJS to JS in Go, Markdown with goldmark) and enters the JS VM once per request.

How it was measured (2026-11-11): Apple M1 Max, 10 cores, macOS 27, Go 1.27.4, hey against 127.0.0.1. Each server was warmed with 500 requests, then hey -z 10s -c 50 and hey -n 2000 -c 1; two rounds, which agreed within 5%. Every response was a 200 with the full page. The 0.x docs kit inlines about 6 KB of CSS into each page where v1 links app.css. Rerun it with bench/run.sh:

cp -R starters/daisyui-docs /tmp/v1-docs
pocketone superuser upsert [email protected] password --dir=/tmp/v1-docs/pb_data
bench/run.sh v1-docs 8804 /docs/overview "$(which pocketone)" /tmp/v1-docs

More in this folder

  • SPEC.md: the full PocketPages v1 contract.
  • fixtures/: the request test cases (go test ./... runs them all).
  • starters/: the starter kits. addons/: JS add-ons such as Datastar.
  • site/: the PocketPages website, itself a PocketPages app.

License

MIT