PocketPages v1 specification
The contract for ext/pages, PocketOne's server-rendered multi-page app extension. The test fixtures
(fixtures/), the starter kits and the PocketPages site are written against this document. Where the
implementation and this document disagree, one of them is a bug.
v1 is a clean API. It keeps PocketPages' ideas (a folder of files that become routes, EJS, layouts, middleware, private partials) and changes the shapes. Apps written for the npm package (0.x) don't run unchanged; "Changes from 0.x" (section 12) lists every difference.
1. The pb_pages folder
Pages live in their own folder next to PocketBase's others:
myapp/
pb_data/ pb_hooks/ pb_migrations/ pb_public/
pb_pages/
- A single app finds it at
pb_pagesnext topb_data, or at--pagesDir(a relative path resolves against the working directory, like--hooksDir). - Serving is automatic: the standard PocketOne binary includes pages, and an app serves its
pb_pageswhenever the folder exists (it starts serving if the folder appears later). There is no on/off switch; no folder, no pages. - Under the multitenant host every instance has its own
pb_pages. - If the folder doesn't exist, the extension does nothing for that app.
- Page code can
require()modules frompb_pages/node_modules, then from the app's usual module paths. The samerequireworks in modules, JSX/TSX components and EJS templates:./xand../xare files inpb_pages, relative to the requiring file, never abovepb_pages. Without an extension,.js,.ts,.tsx,.jsx,.jsonand.cjsare tried, then a folder'spackage.jsonorindex.- A bare name (
pkg,pkg/sub,@scope/pkg) is looked up in the nearestnode_modulesfolder from the requiring file's folder up to the top ofpb_pages. A package resolves through itspackage.json:exports(a string, or conditions, whererequire, thennode, thendefaultare used), elsemain, elseindex.js. Withexports, only what it lists can be required, and no entry may point outside the package. - Anything not found that way goes to the app's own module loader.
node_modulesis never routed or served, and isn't watched: after changing files in it, change any file inpb_pages(or restart) to reload.
- Page code runs in the app's JS VM, so PocketBase's JSVM globals (
$app,Record,$dbx,$security, ...) are available, as far as the sandbox allows for untrusted apps. - Deploying:
pb_pagesis part of an app's code, next topb_hooksandpb_migrations(thedeployextension uploads it the same way). - Pages' own file access (routes, static files, includes, assets) never leaves
pb_pages.
1.1 Special names
A path segment (file or folder name) starting with one of these characters is never a URL:
| Prefix | Meaning |
|---|---|
+ |
Framework file: +config, +middleware, +page, +api (.js or .ts); +layout, +error, +404 (.ejs, .jsx or .tsx). One of each per folder. Any other + name is an error at startup. |
_ |
Private. _private/ folders hold partials and modules for include() and ctx.resolve(). Other _ names are ignored. |
- |
Ignored (drafts, notes). |
. |
Ignored (dotfiles). |
node_modules/ is never routed.
1.2 Groups
A folder named (name) groups files without adding a URL segment: (marketing)/about.ejs serves /about. Groups can
carry their own +layout.ejs, +middleware.js and _private/.
2. Routes
2.1 Pages
A page is a template file: .ejs, .md, .jsx or .tsx (4.5).
| File | URL |
|---|---|
index.ejs |
/ |
about.ejs |
/about |
docs/index.md |
/docs (and /docs/) |
docs/routing.md |
/docs/routing |
products/[id].ejs |
/products/123, with ctx.params.id = "123" |
[org]/[repo]/index.ejs |
/acme/web, with ctx.params = { org: "acme", repo: "web" } |
- The extension is dropped.
indexmaps to its folder. [name]matches exactly one non-empty segment. A segment may mix text and a parameter only as the whole segment;post-[id].ejsis an error at startup.- A URL with a trailing slash matches the same route as without it. Pages never redirect to add or remove one.
- Matching is case-sensitive.
2.2 API routes
A +api.js file serves its folder's URL without a template: feeds/rss/+api.js serves /feeds/rss.
[param] folders work as for pages. If a folder has both +api.js and an index page, startup fails.
2.3 Static files
Every other file is served as-is at its path (groups removed): assets/app.css serves /assets/app.css. Files that
mustn't be served (build inputs such as a Tailwind source) take a - prefix: assets/-app.tailwind.css.
- Content type from the extension.
ETagfrom the content hash;If-None-Matchanswers304.- Fingerprinted URLs (see
ctx.asset, 6.5) are served withCache-Control: public, max-age=31536000, immutable.
2.4 Precedence
/api/...and/_/...are PocketBase's. Pages never see them, and apb_pages/api/folder is an error at startup.- Among matching routes the most specific wins: compare segment by segment from the left; a literal segment beats a
[param]. Soproducts/new.ejsbeatsproducts/[id].ejsfor/products/new. - A static file and a page can't claim the same URL (startup error).
- A URL nothing in
pb_pagesmatches goes on topb_public, then to PocketBase. - If
pb_pages/+404.ejsexists, aGET/HEADthat nothing (pages,pb_public, PocketBase) answers renders it inside the root layout with status404(never for/api/or/_/). The root+middleware.jsruns first; page code can still change the status.
The route table is built when the app starts and rebuilt when anything in pb_pages changes. A rebuild never
restarts the app or reloads pb_hooks. Startup errors (bad names, conflicts) are logged with the file path; the
previous table keeps serving if there was one.
3. Request flow
For a URL that matches a page or API route, one JS call does all of this, in order:
- Plugins'
onRequest(built-in and JS plugins, in config order). - Middleware, root first: every
+middleware.jsfrompb_pages/down to the route's folder (groups included). - Handlers: the folder's
+page.js(or+api.js):load, then the handler for the request method. - Template (pages only), then layouts, innermost first.
- Response.
The call runs under the app's usual JS limits (execution time, memory), as one unit. If any step sends a response
(ctx.res.redirect, json, html, text, file, sse), the later steps are skipped.
3.1 Middleware
// pb_pages/admin/+middleware.js
module.exports = (ctx, next) => {
if (!ctx.auth) return ctx.res.redirect('/login')
ctx.data.section = 'admin'
return next()
}
- One signature:
(ctx, next). Callnext()to continue; return its result. - Returning without calling
next()ends the request. If no response was sent, that's an error (500, and in dev the error page names the middleware file). - Share data through
ctx.data.
3.2 Handlers: +page.js
// pb_pages/products/[id]/+page.js
module.exports = {
load(ctx) {
ctx.data.product = ctx.app.findRecordById('products', ctx.params.id)
},
post(ctx) {
const { qty } = ctx.req.body()
// ...
return ctx.res.redirect(`/products/${ctx.params.id}`, { flash: 'Added to cart' })
},
}
- Exports any of
load,get,post,put,patch,delete. A page that only renders but must accept a form post still needspost() {}: methods without a handler answer405. loadruns for every method; then the method's handler, if there is one.HEADrunsloadandgetlikeGET(only the body is dropped), so keep side effects inpost/put/patch/delete.- A handler may set
ctx.datafields, or return a plain object, which is shallow-assigned intoctx.data(Object.assign). There is no deep merge. - A
+page.jsapplies to every page in its folder.ctx.route.nametells them apart. - A method with no handler still renders the page for
GETandHEAD. Other methods without a handler answer405with anAllowheader listingGET, HEADand the methods that have handlers, in the orderGET, HEAD, POST, PUT, PATCH, DELETE.
3.3 API handlers: +api.js
Same exports as +page.js. There is no template, so a handler must send a response; returning without one is a
500.
// pb_pages/feeds/latest/+api.js
module.exports = {
get(ctx) {
const posts = ctx.app.findRecordsByFilter('posts', 'published = true', '-created', 20)
return ctx.res.json(posts.map((p) => ({ id: p.id, title: p.getString('title') })))
},
}
4. Templates
4.1 EJS
| Tag | Meaning |
|---|---|
<% code %> |
Run JS, no output |
<%= expr %> |
Output, HTML-escaped |
<%- expr %> |
Output, raw |
<%# comment %> |
Nothing |
<%% |
A literal <% |
-%> |
Also remove the newline right after the tag |
<%_ / _%> |
Also remove whitespace before / after the tag on its line |
<script server> code </script> |
Same as <% code %> |
- Escaping replaces
&<>"'with&<>"'.nullandundefinedoutput as an empty string. - A template is compiled once per content hash and reused.
In scope inside a template: ctx, data (= ctx.data), params (= ctx.params), include, slot, asset
(= ctx.asset), meta (= ctx.meta).
A template returns its text. There is no echo; anything you want in the output goes through a tag.
4.2 Partials: include
<%- include('nav') %>
<%- include('card', { product }) %>
include(name, locals?)renders a template from a_private/folder and returns its text. Use<%-so its HTML isn't escaped.- Lookup:
_private/in the current template's folder, then each parent folder up topb_pages/. For a template that is itself inside a_private/folder, the search starts at the folder containing that_private/(so its siblings are found first). A name starting with/looks only inpb_pages/_private/...is not allowed. namemay include its extension; without one,.ejsthen.mdare tried.- The partial sees the same
ctx, pluslocalsas top-level variables. - Including more than 64 levels deep is an error (recursion guard).
4.3 Slots and layouts
+layout.ejs files wrap pages. A page's layouts are every +layout.ejs from its own folder up to pb_pages/,
applied innermost first. A folder may have one layout.
<!-- pb_pages/products/[id].ejs -->
<% slot('title', () => { %><%= data.product.getString('name') %><% }) %>
<h1><%= data.product.getString('name') %></h1>
<!-- pb_pages/+layout.ejs -->
<html>
<head><title><%- slot('title') || 'My shop' %></title></head>
<body><%- slot() %></body>
</html>
slot(name, fn)defines a named slot:fn's output is captured and nothing is output in place.slot(name)returns a slot's content (''if not defined).slot()returns the content being wrapped: the page, or the inner layout.- Named slots defined by the page or by any inner layout are visible to every outer layout. Defining the same name
again replaces it; inside the new definition's
fn,slot(name)still returns the previous content, so a layout can wrap it (slot('title', () => { %>Docs: <%- slot('title') %><% })). - Layouts are skipped when the response isn't HTML (a handler set another
Content-Type) or afterctx.res.layout(false). - A page with an inner extension gets that type:
robots.txt.ejsserves/robots.txtastext/plainandfeed.xml.ejsserves/feed.xmlas XML, so layouts are skipped..html(or no inner extension) is HTML. Page code can still set anotherContent-Type.
4.4 Markdown pages
A .md page is EJS first, then Markdown (CommonMark + GFM tables, strikethrough, task lists, autolinks).
- Front matter between
---lines at the top is YAML, removed before EJS runs. Each top-level key is passed toctx.meta(key, value), for pages and for included.mdpartials. - Headings get
ids: lowercased, characters other thana-z 0-9 - _ spaceremoved, spaces to-; a repeated id gets-1,-2, ... appended. - Fenced code blocks render as
<pre><code class="language-<lang>">. Raw HTML passes through unchanged. - Relative image paths go through
ctx.asset. - A
.mdpartial is rendered (EJS, then Markdown) on its own and inserted as HTML; Markdown never runs twice on the same text. Leave blank lines around anincludein Markdown so the HTML stays a block.
4.5 JSX and TSX
.jsx and .tsx files are templates too: pages, layouts (+layout.tsx), +error/+404, and partials in
_private/. They're compiled when first used by esbuild, which is built into the binary (no Node, no build step), and
cached by content like EJS. TypeScript types are stripped, not checked.
// pb_pages/products/[id].tsx
import Price from './_private/Price'
export default function Product({ data, slot }: any) {
slot('title', data.product.getString('name'))
return (
<article class="product">
<h1>{data.product.getString('name')}</h1>
<Price cents={data.product.getInt('price')} />
</article>
)
}
- The file's default export is a component: a function from props to markup. Props:
ctx,data,params,meta,asset,include,slot,children, plus aninclude's locals. - JSX renders straight to HTML on the server. There's no virtual DOM, no hydration, no client-side React. Event
handler props (functions) are dropped; use plain attributes (
onclick="...",hx-get,data-on:click). - Text and attribute values are escaped.
dangerouslySetInnerHTML={{ __html }}inserts raw HTML.classandclassNameboth work, as doforandhtmlFor.styletakes a string or an object ({ marginTop: 4 }→margin-top:4).truerenders a bare attribute;false,nullandundefinedleave it out. <html>renders with<!doctype html>in front of it (JSX can't write a doctype).- In a layout,
childrenis the inner content.slot(name)reads a named slot,slot(name, content)sets one (content is JSX or a string),slot()is the same aschildren. include(name, locals)returns the partial's HTML, ready to put in JSX. EJS canincludea.jsx/.tsxpartial: its locals become props.- Components are ordinary modules:
import Card from './_private/Card'(resolved likerequire, 3.2). Partials are looked up with extensions in this order:.ejs,.md,.jsx,.tsx.
TypeScript modules. +page.ts, +api.ts, +middleware.ts, +config.ts and any .ts file reached by
require/import work like their .js versions. A module whose only export is export default behaves like
module.exports = ....
5. Responses
| Situation | Response |
|---|---|
| A handler or middleware sent one | That one |
| Page rendered | 200 text/html; charset=utf-8 (or the status set with ctx.res.status) |
HEAD |
As GET, without a body |
| Handler for another method missing | 405 + Allow |
| Error thrown | 500 (see 8) |
JSON is only sent through ctx.res.json (or +api.js). Template output is never parsed as JSON.
6. The ctx object
One object per request, passed to middleware, handlers, templates and plugins.
6.1 ctx.req
| Member | |
|---|---|
method |
"GET", "POST", ... |
url |
{ href, origin, pathname, search } |
query(name) |
First value of a query parameter as a string, or undefined |
query(name, { all: true }) |
Every value, as an array of strings |
query() |
{ name: firstValue } for every parameter |
header(name) |
Request header, '' if absent |
cookie(name) |
Cookie value as a string, or undefined |
cookie(name, { json: true }) |
Cookie parsed as JSON (undefined if absent or not JSON) |
cookies() |
{ name: value }, strings |
body() |
Parsed body: JSON object, or form fields (application/x-www-form-urlencoded, multipart/form-data) as { name: value } (repeated names as arrays) |
files(name) |
Uploaded files for a multipart field, as PocketBase *filesystem.File values ready for a record field |
ip |
Client IP as PocketBase resolves it (trusted proxy settings apply) |
event |
The underlying PocketBase RequestEvent, for anything not covered here |
Query values and cookies are strings unless you ask for JSON. Nothing is parsed for you.
6.2 ctx.res
| Member | |
|---|---|
status(code) |
Status for the rendered page. Returns ctx.res. |
header(name, value) |
Set a response header; returns ctx.res. header(name) reads it back. |
cookie(name, value, options?) |
Set a cookie. Non-string values are JSON.stringify'd; the value is then percent-encoded (encodeURIComponent), and ctx.req.cookie decodes it. Defaults: path=/, HttpOnly, SameSite=Lax, Secure when the request is HTTPS. Options: path, domain, maxAge (seconds), expires (Date), httpOnly, secure, sameSite. |
clearCookie(name, options?) |
Expire a cookie (same path/domain as when set). |
redirect(location, { status = 302, flash }?) |
Send a redirect. flash sets a one-time message (6.4). |
json(data) / json(status, data) |
Send JSON. |
html(html) / html(status, html) |
Send HTML as-is (no layouts). |
text(text) / text(status, text) |
Send plain text. |
file(path) |
Send a file from pb_pages (path relative to pb_pages). |
sse() |
Start a text/event-stream response; returns { send(event, data, { id, retry }?), comment(text), close() }. |
layout(false) |
Render this page without layouts (e.g. an HTML fragment for htmx). |
sent |
true once a response has been sent. |
6.3 Data and routing
| Member | |
|---|---|
ctx.data |
Plain object shared by middleware, handlers and templates. Starts as {}. |
ctx.params |
Route parameters, strings. Never includes query parameters. |
ctx.route |
{ path, name, file }: the URL pattern without groups (/products/[id], /docs for docs/index.md), the file name without extension ([id], index; +api for API routes) and the file relative to pb_pages, groups included. |
6.4 Flash messages
ctx.res.redirect(path, { flash: 'Saved' })stores the message as JSON in a cookie (pp_flash, encoded like any cookie,HttpOnly,Max-Age=300).ctx.flash()on the next request returns it and clears it (Max-Age=0). It returnsundefinedwhen there is none. Calling it again in the same request returns the same value.- A flash can be any JSON value.
6.5 Helpers
| Member | |
|---|---|
ctx.auth |
The authenticated record, or null. Set by PocketBase's own auth (an Authorization header) or by the auth plugin's cookie. |
ctx.app |
The app ($app): records, collections, settings. |
ctx.pb() |
A PocketBase JS SDK client (the bundled synchronous build) acting as the signed-in user (ctx.auth), or as a guest. Its requests go to the app's own API in-process (no network), so collection API rules, expand and field visibility apply exactly as for a browser. API errors throw the SDK's ClientResponseError (e.status, e.response). Only /api/ routes; file uploads through the SDK aren't supported (use ctx.app). |
ctx.env(name) |
Environment variable, '' if unset (the app's environment; under the sandbox, the instance's). |
ctx.log |
debug, info, warn, error: to the app's log. debug only shows in dev. |
ctx.meta(key, value?) |
Per-request key/value store for page metadata (title, description). Set with two arguments, read with one. |
ctx.asset(path) |
URL for a static file in pb_pages, with its content hash: asset('app.css') → /assets/app.3f9a1c2b7d4e.css. Relative paths resolve from the current file's folder; /-paths from pb_pages. A path that isn't a file in pb_pages is returned unchanged. |
ctx.include(name, locals?) |
Render a _private/ partial from JS (handlers, middleware, plugins) and get its text; same lookup as the template include, from the route's folder. |
ctx.resolve(name, { mode }?) |
Load a module from _private/ (same lookup as include; without an extension, the name, then .js, then .json are tried). Modes: require (default, the module's exports), raw (file text), script (text wrapped in <script>), style (wrapped in <style>). |
ctx.pagesDir |
The absolute path of this app's pb_pages folder, e.g. for listing files with $os.readDir. Read-only. |
ctx.store(name, value?) |
The app's in-memory store ($app.store()): set with two arguments, read with one. |
ctx.dev |
true in dev mode. |
Fingerprinted asset names carry the first 12 hex characters of the file's SHA-256: name.<hash>.ext.
7. Configuration: +config.js
// pb_pages/+config.js
module.exports = {
plugins: ['auth', 'realtime', require('./_private/datastar.js')],
debug: false,
}
| Key | Default | |
|---|---|---|
plugins |
[] |
Built-ins by name (auth, realtime), or JS plugin factories such as add-ons (9). EJS and Markdown are always on. |
debug |
false |
Detailed error pages and ctx.log.debug output even outside dev mode. |
Unknown keys, unknown built-in names and duplicates are startup errors.
8. Errors
- Anything thrown in middleware, a handler or a template becomes a
500. - Dev mode or
debug: true: an HTML error page with the message, the file and line (template lines map to the.ejs/.mdsource), and the stack trimmed topb_pages-relative paths. - Otherwise: a plain "Something went wrong" page. The full error always goes to the app's log.
throw ctx.httpError(status, message)sends that status (e.g.404,403) with a plain page showingmessage. In a+api.jsit's JSON:{ "status": 404, "message": "..." }.- A custom error page:
pb_pages/+error.ejsrenders for everyctx.httpErrorand every 500 outside dev mode, withctx.error = { status, message }, inside the root layout.
9. Plugins
9.1 Built-in
Turned on by name in +config.js.
auth: cookie sessions on PocketBase auth, without calling the app over HTTP.
- Every request: reads the
pb_authcookie, validates the token, setsctx.auth(or clears a bad cookie). - The cookie holds
{ token, record }as JSON (the JS SDK's cookie format),HttpOnly,SameSite=Lax,Secureon HTTPS, lasting as long as the collection's auth token. identityarguments accept any of the collection's identity fields (email by default).ctx.session.authMethods({ collection })→{ password: { enabled, identityFields }, otp: { enabled }, mfa: { enabled }, oauth2: { enabled, providers: [{ name, displayName }] } }, as PocketBase's auth-methods API.ctx.session.register(email, password, { collection = 'users', data, verify = true, signIn = true }): creates the record; sends the verification email whenverify; signs in whensignIn.ctx.session.signInWithPassword(identity, password, { collection })ctx.session.requestOTP(email, { collection, create = false })→otpId. Always returns an id (unknown emails get one that never validates, as PocketBase does); withcreate: truea missing record is created first.ctx.session.signInWithOTP(otpId, code, { collection })ctx.session.signInAnonymously({ collection })ctx.session.requestOAuth2(provider, { collection, redirectPath = '/auth/oauth/confirm', redirect = true }): stores state in a cookie and returns the provider's URL; withredirectit also sends the redirect (ctx.res.sent).redirectPathmust be a path on this site. PKCE is used when the provider supports it.ctx.session.signInWithOAuth2(state, code, { createData }?): completes it on the redirect path (the state cookie carries the provider, collection and PKCE verifier; it lasts 10 minutes and works once). The sign-in runs through PocketBase's ownauth-with-oauth2endpoint in-process, so sign-up, account linking (to the signed-in account, if any), the anti-hijacking checks andonRecordAuthWithOAuth2Requesthooks behave as in PocketBase.createDatais extra data for a new record. Returns{ token, record, meta }(metais PocketBase's OAuth2 meta, withisNew). Accounts that need MFA throw an error: pages doesn't do MFA yet.- Apple answers with a POST, so read
stateandcodefromctx.req.body()there. On HTTPS the state cookie isSameSite=Noneso it survives that cross-site POST. ctx.session.signIn(record): set the cookie for a record you authenticated yourself.ctx.session.signOut()- Email flows, with PocketBase's own checks and mail templates:
requestVerification(email, { collection }),confirmVerification(token),requestPasswordReset(email, { collection }),confirmPasswordReset(token, password, passwordConfirm),requestEmailChange(newEmail)(needsctx.auth),confirmEmailChange(token, password). Request methods never reveal whether an email exists. - Every sign-in method sets
ctx.authfor the rest of the request and returns{ token, record }.
realtime: PocketBase realtime from page code.
ctx.realtime.send(topic, data, { filter }?)sends to every client subscribed totopic(the filter only narrows that set).datais always JSON-encoded (a string arrives as a JSON string, as PocketBase's own messages do).filter(client)returnstrueto include a client.clientis PocketBase's realtime client:id(),get(key),set(key, value),hasSubscription(topic).ctx.realtime.client(id)returns one client by id, ornull.- Sending never waits for slow clients: each message is handed to the client's queue in the background.
9.2 JS plugins
A JS plugin is a factory that returns hooks:
module.exports = (config) => ({
name: 'my-plugin',
onRequest(ctx) {}, // before middleware
extendContext(ctx) { ctx.hello = () => 'hi' },
transform({ html, ctx, file }) { return html }, // after each page or layout renders (not partials)
})
configis the plugin's entry in+config.jswhen it's an object ({ factory, ...options }), else{}.nameis required and unique.- There's no hook for treating other kinds of files as pages: a page's inner extension already does that
(
robots.txt.ejsserves/robots.txtastext/plain, 4.5). - Hooks run in config order. Every hook is optional.
- JS plugins run with the same permissions as the rest of the app's JS (sandboxed in a multitenant host).
9.3 Add-ons
Plugins for third-party libraries ship as JS files in ext/pages/addons/, not built into ext/pages. Copy one into
pb_pages (usually _private/) and list it in +config.js with require.
datastar.js (Datastar) adds ctx.datastar:
ctx.datastar.scripts({ spa: { scope, selector }, realtime }?)returns the script tags (spaturns on client-side navigation for links underscope, swappingselector). Withrealtime: truethe page subscribes to thedatastarrealtime topic and sets a$clientIdsignal to its realtime client id.ctx.datastar.patchElements(html, { selector, mode }?),ctx.datastar.patchSignals(signals, { onlyIfMissing }?)(signalsis an object),ctx.datastar.executeScript(js)send SSE events on the current response. The first one starts the stream (ctx.res.sentbecomestrue); later steps still run, but their output isn't sent and the automatic patch below doesn't happen. The stream closes when the request ends.ctx.datastar.signals()returns the request's signals as an object (from thedatastarquery parameter onGET, the JSON body otherwise).modeis one ofouter(default),inner,replace,prepend,append,before,after,remove.- When a request has
Datastar-Request: true, the rendered page (without layouts) is sent as apatchElementsevent, intoDatastar-Selectorif given. ctx.datastar.realtime.patchElements(html, options?, { filter }?),.patchSignals(signals, options?, { filter }?)and.executeScript(js, { filter }?)send the same events todatastarrealtime subscribers (needsrealtime).
10. Dev mode
On when the app runs in dev mode (--dev) or +config.js sets debug: true:
- detailed error pages (8);
ctx.log.debugoutput;- template cache keyed by content hash, so edits show on the next request (the route table is rebuilt on any file change in every mode).
11. Starter kits
pocketone pages create <name> [--starter <kit>] creates the folder <name> (it must not exist) and copies the
whole kit into it. Run it with cd <name> && pocketone serve --dir ./pb_data (pocketone keeps pb_data next to its
executable by default; pb_pages and pb_migrations are found next to pb_data). pocketone pages starters lists
the kits. Kits: minimal (default), mvp, htmx,
daisyui, daisyui-docs, datastar, auth. The command needs a build that includes ext/pages. It copies
pb_pages/ plus anything beside it (pb_migrations/, README.md, a package.json for CSS tooling). Kits ship
inside ext/pages (starters/).
12. Changes from 0.x (npm PocketPages)
| 0.x | v1 |
|---|---|
pb_hooks/pages/, booted by require('pocketpages') in a .pb.js |
pb_pages/, no boot file, no npm install |
api object, re-spread into each call |
ctx with req, res, data, params, route, helpers |
Query parameters mixed into params, JSON-parsed |
ctx.params is route parameters only; ctx.req.query() returns strings |
| Cookies JSON-parsed on read | Strings; { json: true } to parse |
Middleware: return a value, or call next(extra), chosen by arity |
(ctx, next), data on ctx.data |
+load.js, +get.js, +post.js, ... per folder, results deep-merged |
One +page.js exporting load, get, post, ...; returned objects shallow-assigned |
| Template output that parses as JSON is sent as JSON | Only ctx.res.json or +api.js |
<!-- slot:name --> comments; layouts read slot / slots |
slot(name, fn) to define, slot(name) / slot() to read |
echo() writes during render |
Templates return text; streaming only through ctx.res.sse() |
redirect(path, { message }) puts the flash in ?__flash= |
ctx.res.redirect(path, { flash }), one-time cookie, ctx.flash() |
asset() fingerprint: full SHA-256 |
First 12 hex characters |
+config.js plugins: npm package names, EJS options |
Built-in names or JS factories; EJS and Markdown always on |
auth plugin through the JS SDK over HTTP (api.pb()) |
ctx.session.*, direct app calls |
url(), stringify(), micro-dash globals |
Removed: use ctx.req.url, JSON.stringify, plain JS |
pb() (js-sdk plugin over HTTP to localhost:8090) |
ctx.pb(): same SDK, built in, in-process, as the signed-in user |
| Route with fewer segments could match a longer URL | Segment counts must match |
create-pocketpages |
pocketone pages create <name> [--starter <kit>] |
Questions and comments
Comments are a GitHub Discussions thread; sign in with GitHub to post.