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
authplugin 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
pocketonebinary 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.fileandassetnever reach outside the instance'spb_pages;$osfile 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)renderspb_pages/_private/oauth/signin.ejs(or.md,.jsx,.tsx) as the whole response, withdataasctx.dataand no middleware, layouts or+page.js, for any method. It returnshandled=falsewhen 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.jscan add toctx, 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
Questions and comments
Comments are a GitHub Discussions thread; sign in with GitHub to post.