## Documentation: # Overview PocketPages turns a folder of files into a server-rendered website, inside PocketBase. It is built into PocketBase CE as the `pages` plugin (`ext/pages`): no npm package, no boot file, no build step. Use PocketPages to create slim and modern web apps: - Start small and iterate - SEO-first principles - No build step - Fast server-side rendering ## Hello world in just one file ` <%= `Hello, world!` %> ` http://localhost:8090 Hello, world! Inside `<%` and `%>` you write plain JavaScript. Every page gets a `ctx` object with the request, the response, your data, and the app itself (`ctx.app`), so you can read and write records directly. ## Retro file-based routing Files become URLs, and `+layout.ejs` files wrap the pages below them: ` <%- slot() %> ` http://localhost:8090 Hello, world! ## Coming from the npm package? PocketPages v1 is a clean API. Apps written for PocketPages 0.x (the npm package) don't run unchanged. See [Moving from PocketPages 0.x](/docs/moving-from-0x). ## Documentation: /overview # Overview PocketPages turns a folder of files into a server-rendered website, inside PocketBase. It is built into PocketBase CE as the `pages` plugin (`ext/pages`): no npm package, no boot file, no build step. Use PocketPages to create slim and modern web apps: - Start small and iterate - SEO-first principles - No build step - Fast server-side rendering ## Hello world in just one file ` <%= `Hello, world!` %> ` http://localhost:8090 Hello, world! Inside `<%` and `%>` you write plain JavaScript. Every page gets a `ctx` object with the request, the response, your data, and the app itself (`ctx.app`), so you can read and write records directly. ## Retro file-based routing Files become URLs, and `+layout.ejs` files wrap the pages below them: ` <%- slot() %> ` http://localhost:8090 Hello, world! ## Coming from the npm package? PocketPages v1 is a clean API. Apps written for PocketPages 0.x (the npm package) don't run unchanged. See [Moving from PocketPages 0.x](/docs/moving-from-0x). ## Documentation: /moving-from-0x # Moving from PocketPages 0.x PocketPages v1 is part of PocketBase CE (the `pages` plugin). It keeps the ideas of the npm package (a folder of files that become routes, EJS, layouts, middleware, private partials) and changes the shapes. Apps written for 0.x don't run unchanged. There is no migration tool. Start a new app with `pocketbase pages create `, then move your pages over using the list below. The npm package is being retired. ## Folder and boot 0.x v1 `pb_hooks/pages/`, booted by `require('pocketpages')` in a `.pb.js` file `pb_pages/` next to `pb_data`. No boot file, no npm install. `create-pocketpages` `pocketbase pages create [--starter ]` Delete the `.pb.js` boot file and the `pocketpages` dependencies, and move the contents of `pb_hooks/pages/` to `pb_pages/`. ## The context object 0.x passed an `api` object and spread its members into each template. v1 passes one `ctx` object with `req`, `res`, `data`, `params`, `route` and helpers. See the [ctx reference](/docs/ctx). 0.x v1 `request`, `response` `ctx.req`, `ctx.res` `body()`, `formData` `ctx.req.body()` `redirect(path)` `ctx.res.redirect(path)` `env()`, `store()`, `dbg()`, `info()`, `warn()`, `error()` `ctx.env()`, `ctx.store()`, `ctx.log.debug()` / `info()` / `warn()` / `error()` `auth`, `request.auth` `ctx.auth` Inside templates, `data`, `params`, `asset` and `meta` are still available without the `ctx.` prefix. ## Parameters, query strings and cookies - Query parameters are no longer mixed into `params`, and nothing is JSON-parsed for you. `ctx.params` holds route parameters only; read the query string with `ctx.req.query(name)`, which returns strings. - Cookies are strings. Pass `{ json: true }` to parse one: `ctx.req.cookie('prefs', { json: true })`. ## Middleware 0.x middleware either returned a value or called `next(extra)`, depending on how many arguments it declared. v1 has one signature: `module.exports = (ctx, next) => { ctx.data.user = ctx.auth return next() } ` Put shared data on `ctx.data`. See [Middleware](/docs/middleware). ## Loaders `+load.js`, `+get.js`, `+post.js` and friends are replaced by one `+page.js` per folder that exports `load`, `get`, `post`, `put`, `patch` and `delete`. Returned objects are shallow-assigned into `ctx.data` (no deep merge). `// 0.x: +load.js module.exports = (api) => ({ products: $app.findAllRecords('products') }) // v1: +page.js module.exports = { load(ctx) { return { products: ctx.app.findAllRecords('products') } }, } ` See [Loading Data](/docs/loading-data). ## JSON 0.x sent template output as JSON if it parsed as JSON. v1 never does. Send JSON with `ctx.res.json()`, usually from a `+api.js` file. See [API Routes and JSON](/docs/api-routes). ## Layouts and slots 0.x v1 `` comments in the page `slot('name', () => { %>...<% })` `slot` and `slots.name` in layouts `slot()` and `slot('name')` See [Layouts and Slots](/docs/layouts). ## Output `echo()` is gone. A template returns its text; anything you want in the output goes through a tag. For streaming, use `ctx.res.sse()`. ## Flash messages 0.x put the message in a `?__flash=` query parameter with `redirect(path, { message })`. v1 uses a one-time cookie: `return ctx.res.redirect('/account', { flash: 'Saved' }) ` Read it on the next request with `ctx.flash()`. See [flash](/docs/ctx/flash). ## Assets `asset()` URLs now carry the first 12 hex characters of the file's SHA-256, not the full hash. ## Configuration and plugins - `+config.js` lists built-in plugins by name (`auth`, `realtime`, `datastar`) or JS plugin factories. npm plugin package names and EJS options are gone. - EJS and Markdown are always on. `pocketpages-plugin-ejs` and `pocketpages-plugin-marked` have no v1 equivalent because they aren't needed. - The auth plugin no longer goes through the JS SDK over HTTP (`api.pb()`). Use `ctx.session.*`, which calls the app directly. See [Authentication](/docs/plugins/auth). See [+config.js](/docs/config) and [Plugins](/docs/plugins). ## Removed globals 0.x v1 `url()` `ctx.req.url` `stringify()` `JSON.stringify` micro-dash plain JavaScript `pb()` (JS SDK plugin) `ctx.pb()`, built in: same SDK, no host setting, runs in-process as the signed-in user ## Routing A route with fewer segments no longer matches a longer URL. Segment counts must match: `products/[id].ejs` serves `/products/123` but not `/products/123/reviews`. ## Documentation: /installation # Installation PocketPages v1 is built into PocketBase CE as the `pages` plugin (`ext/pages`). There is nothing to install from npm. You need a PocketBase CE binary with the plugin. ### Step 1: Install PocketBase CE and create an app. `brew install pockethost/tap/pocketbase pocketbase pages create myapp` ### Step 2: Run. ``` cd myapp pocketbase serve --dir ./pb_data --dev ``` `pocketbase pages create myapp` creates a new app. Add `--starter ` to start from a [starter kit](/docs/starter-kits). Note: `--dir` tells PocketBase where `pb_data` is. PocketPages looks for `pb_pages` next to it. Browse to `http://localhost:8090` and you'll see your home page: http://localhost:8090 Hello, world! To start editing, open `myapp/pb_pages/index.ejs ` Changes appear on the next refresh. The app doesn't restart. ## Where pages live Pages live in their own folder next to PocketBase's others: `myapp/ pb_data/ pb_hooks/ pb_migrations/ pb_public/ pb_pages/ ` - PocketPages finds the folder at `pb_pages` next to `pb_data`, or wherever `--pagesDir` points. - If the folder doesn't exist, the plugin does nothing for that app. - Under a multitenant host, every instance has its own `pb_pages`. ## How does it work? PocketPages is a multi-page app server for PocketBase. It listens for requests and renders pages using file-based routing rules, like old-school PHP. URLs under `/api/` and `/_/` always belong to PocketBase. A URL nothing in `pb_pages` matches goes on to `pb_public`, then to PocketBase. ## Documentation: /starter-kits # Starter Kits To start a new app from a starter kit, run: `pocketbase pages create myapp --starter ` Without `--starter`, `pocketbase pages create myapp` creates a plain app (the `minimal` kit). `pocketbase pages starters` lists the kits. The folder must not exist yet. Then run it: `cd myapp pocketbase serve --dir ./pb_data ` PocketBase keeps `pb_data` next to its executable unless you pass `--dir`; `pb_pages` and `pb_migrations` are found next to `pb_data`. Each starter kit is an ordinary `pb_pages` folder written against the v1 API, so everything in these docs applies to it. Starter What it shows Live demo minimal A minimal PocketPages starter with just the basics [pocketpages-minimal.pockethost.io](https://pocketpages-minimal.pockethost.io) mvp MVP.css, a minimalist classless CSS framework [pocketpages-mvp.pockethost.io](https://pocketpages-mvp.pockethost.io) auth Password, anonymous, OTP and OAuth2 sign-in, plus verification, password reset and email change [pocketpages-auth.pockethost.io](https://pocketpages-auth.pockethost.io) daisyui Tailwind CSS with DaisyUI components [pocketpages-daisyui.pockethost.io](https://pocketpages-daisyui.pockethost.io) daisyui-docs Documentation site template with DaisyUI and navigation [pocketpages-daisyui-docs.pockethost.io](https://pocketpages-daisyui-docs.pockethost.io) htmx HTMX integration for dynamic interactions [pocketpages-htmx.pockethost.io](https://pocketpages-htmx.pockethost.io) datastar Realtime chat and counter using Datastar [pocketpages-datastar.pockethost.io](https://pocketpages-datastar.pockethost.io) ## Documentation: /creating-a-page # Creating a PocketPages Page A page is a template file, `.ejs` or `.md`, anywhere in `pb_pages/`. Its path becomes its URL. ## Creating pages - Create a file: inside `pb_pages/`, create an `.ejs` file. For example, an "About" page: `pb_pages/about.ejs -> serves "/about" ` - `index` files: `index` maps to its folder. ``` pb_pages/index.ejs -> serves "/" pb_pages/products/index.ejs -> serves "/products" (and "/products/") ``` - Nested pages: folders become URL segments. ``` pb_pages/ ├── index.ejs -> "/" ├── about.ejs -> "/about" ├── contact.ejs -> "/contact" └── products/ ├── index.ejs -> "/products" └── details.ejs -> "/products/details" ``` - Markdown pages: a `.md` file works the same way. It runs through EJS first, then Markdown. See [Markdown](/docs/markdown). ``` pb_pages/docs/routing.md -> "/docs/routing" ``` The extension is always dropped from the URL. To add data or handle forms, put a `+page.js` next to the page; see [Loading Data](/docs/loading-data). ## Documentation: /directory-structure # Directory Structure All pages live in the `pb_pages/` folder, next to `pb_data/`: `myapp/ pb_data/ pb_hooks/ pb_migrations/ pb_public/ pb_pages/ +config.js +layout.ejs index.ejs about.ejs _private/ nav.ejs assets/ app.css products/ [id]/ +page.js index.ejs ` ## Special names A file or folder name starting with one of these characters is never a URL: Prefix Meaning `+` Framework file: `+config.js`, `+layout.ejs`, `+middleware.js`, `+page.js`, `+api.js`. 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 either. ## Groups A folder named `(name)` groups files without adding a URL segment: `(marketing)/pricing.ejs` serves `/pricing`. A group can have its own `+layout.ejs`, `+middleware.js` and `_private/`. ## What else - Every other file is a [static file](/docs/static-content), served as-is. - Page code can `require()` modules from `pb_pages/node_modules`, then from the app's usual module paths. - Pages' own file access (routes, static files, includes, assets) never leaves `pb_pages`. - A `pb_pages/api/` folder is an error at startup, because `/api/` belongs to PocketBase. ## Documentation: /routing # File-Based Routing URLs come from the files and folders in `pb_pages/`. ## Pages 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. `index` maps to its folder. - `[name]` matches exactly one non-empty segment. A parameter must be the whole segment: `post-[id].ejs` is an error at startup. - Segment counts must match: `products/[id].ejs` doesn't match `/products/123/reviews`. - 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. See [Accessing Parameters](/docs/parameters). ## Groups A folder named `(name)` doesn't add a URL segment. Use groups to give part of the site its own layout or middleware: `pb_pages/ (site)/ +layout.ejs # wraps everything in (site) index.ejs # / about.ejs # /about xapi/ count.ejs # /xapi/count, outside the (site) layout ` ## 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. A folder can't have both a `+api.js` and an `index` page. See [API Routes and JSON](/docs/api-routes). ## Static files Every other file is served as-is at its path, with groups removed: `assets/app.css` serves `/assets/app.css`. See [Static content](/docs/static-content). ## Which route wins - `/api/...` and `/_/...` belong to PocketBase. Pages never see them. - Among matching routes the most specific wins: segments are compared from the left, and a literal segment beats a `[param]`. So `products/new.ejs` beats `products/[id].ejs` for `/products/new`. - A static file and a page can't claim the same URL (startup error). - A URL nothing in `pb_pages` matches goes on to `pb_public`, then to PocketBase. ## When routes change The route table is built when the app starts and rebuilt whenever 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, and the previous route table keeps serving. ## Documentation: /parameters # Route and Query Parameters Route parameters come from the URL path. Query parameters come from the query string. v1 keeps them apart. ## Route parameters Use square brackets in file or folder names to capture a URL segment: `pb_pages/ products/ [productId]/ index.ejs reviews/ [reviewId].ejs ` This handles URLs like `/products/123` and `/products/123/reviews/456`. Read them through `params` (the same object as `ctx.params`): `

Product: <%= params.productId %>

Review: <%= params.reviewId %>

` Route parameters are always strings. `ctx.params` never includes query parameters. ## Query parameters Read the query string with `ctx.req.query()`: `/products/123?sort=latest&tag=red&tag=blue ` ```

Sort by: <%= ctx.req.query('sort') %>

Tags: <%= ctx.req.query('tag', { all: true }).join(', ') %>

``` Call Returns `ctx.req.query(name)` First value as a string, or `undefined` `ctx.req.query(name, { all: true })` Every value, as an array of strings `ctx.req.query()` `{ name: firstValue }` for every parameter Values are strings. Nothing is parsed for you; if you pass JSON in a query parameter, parse it yourself: `const filters = JSON.parse(ctx.req.query('filters') ?? '{}') ` ## The route itself `ctx.route` describes the matched route: `{ path, name, file }`. For `products/[productId]/index.ejs`, `path` is `/products/[productId]`, `name` is `index`, and `file` is `products/[productId]/index.ejs`. ## Documentation: /loading-data # Loading Data in PocketPages You can load data right in a template, or in a `+page.js` file next to it. ## Inline data loading The simplest way is a `<% %>` block at the top of your template: `<% const products = ctx.app.findRecordsByFilter('products', 'active = true', 'name', 100) %>

Products

<% for (const product of products) { %>

<%= product.getString('name') %>

<% } %> ` ## Server script blocks For better formatting support in editors, you can write `

Products by Category

<% for (const category of categories) { %>

<%= category.getString('name') %>

<% } %> ` ## Structured loading with `+page.js` A `+page.js` file keeps data loading and form handling out of the template: `// 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' }) }, } ` ```

<%= data.product.getString('name') %>

<% const flash = ctx.flash() %> <% if (flash) { %>

<%= flash %>

<% } %> ``` ### Exports `+page.js` exports any of `load`, `get`, `post`, `put`, `patch` and `delete`. - `load` runs first, for every method. - Then the handler for the request method runs, if there is one. - Then the page renders. A handler can set fields on `ctx.data`, or return a plain object. A returned object is shallow-assigned into `ctx.data` (`Object.assign`); there is no deep merge. `module.exports = { load(ctx) { return { departments: ctx.app.findAllRecords('departments') } }, get(ctx) { ctx.data.mode = 'view' }, } ` If a handler sends a response (`ctx.res.redirect`, `json`, `html`, `text`, `file`, `sse`), the page doesn't render. ### One `+page.js` per folder A `+page.js` applies to every page in its folder. Use `ctx.route.name` to tell them apart: `module.exports = { load(ctx) { if (ctx.route.name === 'details') { ctx.data.reviews = ctx.app.findRecordsByFilter('reviews', 'product = {:id}', '-created', 20, 0, { id: ctx.params.id }) } }, } ` ### Methods without a handler - `GET` and `HEAD` still render the page. - Other methods answer `405`, with an `Allow` header listing `GET, HEAD` and the methods that have handlers. ## Example: a contact form ``` // pb_pages/contact/+page.js module.exports = { load(ctx) { return { departments: ctx.app.findAllRecords('departments') } }, post(ctx) { const { email, message, department } = ctx.req.body() try { const record = new Record(ctx.app.findCollectionByNameOrId('messages')) record.set('email', email) record.set('message', message) record.set('department', department) ctx.app.save(record) } catch (e) { return { error: 'Failed to send message', values: { email, message, department } } } return ctx.res.redirect('/contact', { flash: 'Thanks, we got your message.' }) }, } ``` ``` <% const flash = ctx.flash() %> <% if (flash) { %>

<%= flash %>

<% } %> <% if (data.error) { %><%= data.error %><% } %>
``` ## Middleware or `+page.js`? - Only the route's own folder's `+page.js` runs. - Every `+middleware.js` from `pb_pages/` down to the route's folder runs, root first. Use [middleware](/docs/middleware) for data or checks that a whole section of the site shares. ## Documentation: /api-routes # API Routes and JSON A `+api.js` file serves its folder's URL without a template. Use it for JSON, feeds, webhooks and anything else that isn't an HTML page. `// 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') }))) }, } ` A request to `/feeds/latest` returns: `[{ "id": "abc123", "title": "Hello" }] ` ## How it works - `+api.js` has the same exports as `+page.js`: `load`, `get`, `post`, `put`, `patch`, `delete`. - There is no template, so a handler must send a response. Returning without one is a `500`. - `[param]` folders work as for pages: `products/[id]/+api.js` serves `/products/123`. - A folder can't have both a `+api.js` and an `index` page. - Middleware runs for API routes too. - `ctx.httpError()` thrown in a `+api.js` answers with JSON: `{ "status": 404, "message": "..." }`. ## Sending responses Call Sends `ctx.res.json(data)` / `ctx.res.json(status, data)` JSON `ctx.res.text(text)` / `ctx.res.text(status, text)` Plain text `ctx.res.html(html)` / `ctx.res.html(status, html)` HTML as-is, without layouts `ctx.res.file(path)` A file from `pb_pages` `ctx.res.redirect(location)` A redirect `ctx.res.sse()` A `text/event-stream` See [res](/docs/ctx/res). ## JSON from a page JSON is only sent through `ctx.res.json` (or a `+api.js`). Template output is never parsed as JSON, even if it looks like JSON. A page's `+page.js` can still answer with JSON for one method: `// pb_pages/products/[id]/+page.js module.exports = { post(ctx) { const product = ctx.app.findRecordById('products', ctx.params.id) return ctx.res.json({ id: product.id, name: product.getString('name') }) }, } ` ## Keep `/api/` free `/api/` belongs to PocketBase. Pages never see it, and a `pb_pages/api/` folder is an error at startup. Put your routes somewhere else, such as `feeds/`, `xapi/` or `hooks/`. ## Documentation: /ejs # EJS EJS is always on. There is nothing to install or configure. ## Tags 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 `` Same as `<% code %>` ## Escaping `<%= %>` replaces `&` `<` `>` `"` `'` with `&` `<` `>` `"` `'`. `null` and `undefined` output as an empty string. `

<%= '"x"' %>

<%- 'raw' %>

<%= null %>

` Use `<%-` only for HTML you trust, such as the output of `include()` or `slot()`. ## In scope Inside a template you can use: Name `ctx` The [request context](/docs/ctx) `data` `ctx.data` `params` `ctx.params` `include` Render a [partial](/docs/partials) `slot` Define or read a [slot](/docs/layouts) `asset` `ctx.asset` `meta` `ctx.meta` ## Output A template returns its text. There is no `echo()`; anything you want in the output goes through a tag. `
    <% for (const n of [1, 2]) { -%>
  • <%= n %>
  • <% } -%>
` ## Caching A template is compiled once per content hash and reused, so an edited file is recompiled on the next request. ## Documentation: /markdown # Markdown Support A `.md` file is a page, just like an `.ejs` file. Markdown is always on. ## How it works - EJS first: the file runs through EJS, so every [EJS tag](/docs/ejs) works in Markdown. - Then Markdown: the result is rendered as Markdown: CommonMark plus GFM tables, strikethrough, task lists and autolinks. ## Front matter YAML between `---` lines at the top of the file is front matter. Each top-level key is passed to `ctx.meta(key, value)`: `--- title: My Blog Post description: A detailed guide about something interesting --- # Content starts here... ` Read the values in a layout with `meta`: `<%= meta('title') %> ` ## Headings Headings get `id`s, so you can link to them: the text is lowercased, characters other than `a-z`, `0-9`, `-`, `_` and space are removed, and spaces become `-`. `## Front matter` gets `id="front-matter"`. ## Images Relative image paths go through [`ctx.asset`](/docs/ctx/asset), so they get fingerprinted URLs: `![Logo](logo.png) ` ## Markdown partials A `.md` file in a `_private/` folder can be included from EJS with `include()`. It's rendered the same way, EJS then Markdown: ``` <%- include('intro') %> ``` ## Notes - Layouts are always `+layout.ejs`. A layout can't be Markdown. ## Documentation: /partials # Using Partials in PocketPages Partials are reusable templates that you include from other templates. They live in `_private/` folders, which are never routed. ## Including a partial `<%- 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. - `name` may omit the extension; `.ejs` then `.md` are tried. - The partial sees the same `ctx`, plus `locals` as top-level variables. - Including more than 64 levels deep is an error. ## Where partials are found ``` pb_pages/ _private/ header.ejs footer.ejs blog/ _private/ sidebar.ejs posts/ _private/ post-card.ejs index.ejs ``` PocketPages looks in `_private/` in the current template's folder, then in each parent folder up to `pb_pages/`. ` <%- include('post-card', { post }) %> <%- include('sidebar') %> <%- include('header') %> ` A name starting with `/` looks only in `pb_pages/_private/`: `<%- include('/header') %> ` This means you can: - Keep section-specific partials in that section's `_private/`. - Hoist a shared partial up to a common parent. - Override a parent's partial with a local one of the same name, and still reach the root one with `/name`. ## Passing data Pass only what the partial needs: `<%- include('user-card', { name: user.getString('name'), joined: user.getString('created') }) %> ` ```

<%= name %>

Joined <%= joined %>

``` Partials can also read `ctx`, `data` and `params` directly. Passing values explicitly keeps them easy to reuse. ## Markdown partials A `.md` partial is rendered as EJS, then Markdown: `<%- include('intro') %> ` ## JavaScript modules `_private/` folders also hold JS modules. Load them with [`ctx.resolve()`](/docs/ctx/resolve), which uses the same lookup. ## Documentation: /layouts # Layouts and Slots `+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/ +layout.ejs # applied last (outermost) products/ +layout.ejs # applied first [id].ejs ` For `/products/123`, `[id].ejs` renders, then `products/+layout.ejs` wraps it, then `+layout.ejs` wraps that. ## `slot()` In a layout, `slot()` returns the content being wrapped: the page, or the inner layout. ` My Application
<%- slot() %>
© <%= new Date().getFullYear() %> My Application
` ## Named slots A page (or an inner layout) defines a named slot with `slot(name, fn)`. `fn`'s output is captured and nothing is output in place. A layout reads it with `slot(name)`, which returns `''` if it isn't defined. ` <% slot('title', () => { %><%= data.product.getString('name') %><% }) %>

<%= data.product.getString('name') %>

` ``` <%- slot('title') || 'My shop' %> <%- slot() %> ``` - 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') %><% }) %>
<%- slot() %>
``` ## Data in layouts Layouts render after the page, with the same `ctx`. Everything middleware, `+page.js` and the page put on `ctx.data` or `ctx.meta` is there. ## When layouts are skipped - When the response isn't HTML (a handler set another `Content-Type`). - When a handler or middleware sent the response itself (`ctx.res.json`, `ctx.res.html`, `ctx.res.redirect`, ...). - For `+api.js` routes, which have no template. - With the [datastar plugin](/docs/plugins/datastar), for requests that carry `Datastar-Request: true`. To serve HTML fragments without the site layout, put the layout in a group (`(site)/+layout.ejs`) and the fragments outside it, or send them with `ctx.res.html()`. ## Documentation: /private-files # Working with Private Files and Directories ## Names that are never routed Prefix Meaning `+` Framework file (`+layout.ejs`, `+page.js`, ...) `_` Private. `_private/` folders hold partials and modules. Other `_` names are ignored. `-` Ignored (drafts, notes) `.` Ignored (dotfiles) `pb_pages/ _private/ # private (not routable) -drafts/ # ignored helpers/ # normal folder (routable) +page.js # framework file (not routable) +layout.ejs # framework file (not routable) index.ejs # page ` ## The `_private` folder `_private/` folders serve two purposes: - Template partials, rendered with [`include()`](/docs/partials) - JavaScript modules and other files, loaded with [`ctx.resolve()`](/docs/ctx/resolve) Both use the same lookup: `_private/` in the current template's folder, then each parent folder up to `pb_pages/`. A name starting with `/` looks only in `pb_pages/_private/`. `pb_pages/ _private/ header.ejs format.js products/ _private/ product-card.ejs queries.js index.ejs ` ``` <% const queries = ctx.resolve('queries') // products/_private/queries.js const format = ctx.resolve('format') // _private/format.js %> <%- include('header') %> <% for (const p of queries.featured(ctx.app)) { %> <%- include('product-card', { p, format }) %> <% } %> ``` ## Modules A module in `_private/` is a CommonJS file: ``` // pb_pages/products/_private/queries.js module.exports = { featured: (app) => app.findRecordsByFilter('products', 'featured = true', '-created', 10), } ``` ## Notes - Files in `_private/` are never served. - Keep secrets in environment variables (see [Managing Secrets](/docs/secrets)), not in files. - Keep private files close to where they're used, and hoist them up when several sections share them. ## Documentation: /middleware # Middleware Middleware files (`+middleware.js`) run before your handlers and templates. Use them to: - Check authentication - Load data a whole section shares - Set headers - Validate requests ## Basic example `// 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)`. Call `next()` to continue, and return its result. - Returning without calling `next()` ends the request. If no response was sent, that's an error (a `500`; in dev mode the error page names the middleware file). - Share data through `ctx.data`. ## Order Every `+middleware.js` from `pb_pages/` down to the route's folder runs, root first. Groups count too. `pb_pages/ +middleware.js # runs first (all routes) products/ +middleware.js # runs second (/products/...) [id]/ +middleware.js # runs third (/products/[id]) ` The full order for a request is: - Plugins' `onRequest` - Middleware, root first - The folder's `+page.js` or `+api.js` (`load`, then the method's handler) - The template, then layouts, innermost first - The response If any step sends a response, the later steps are skipped. ## Common uses ### Auth guard ``` module.exports = (ctx, next) => { if (!ctx.auth) return ctx.res.redirect('/login') return next() } ``` ### Request validation ``` // pb_pages/products/[id]/+middleware.js module.exports = (ctx, next) => { if (!/^[a-z0-9]+$/.test(ctx.params.id)) throw ctx.httpError(400, 'Invalid ID') return next() } ``` ### Shared data ``` // pb_pages/+middleware.js module.exports = (ctx, next) => { ctx.data.categories = ctx.app.findRecordsByFilter('categories', 'active = true', 'name', 100) return next() } ``` ``` ``` ### Headers ``` module.exports = (ctx, next) => { ctx.res.header('X-Frame-Options', 'DENY') return next() } ``` ## Documentation: /asset-management # Asset Management Static files in `pb_pages` (images, CSS, JS, fonts) can be served with fingerprinted URLs that change when the file changes. ## Where to put assets Anywhere in `pb_pages`, next to the pages that use them or in a shared folder: `pb_pages/ assets/ app.css logo.png feature/ index.md header.jpg ` ## In templates Use `asset()` (the same as `ctx.asset()`): ` ` `asset('/assets/app.css')` returns something like `/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. ## In Markdown Relative image paths go through `asset()` automatically: `![Header](header.jpg) ` ## How it works - A fingerprinted name carries the first 12 hex characters of the file's SHA-256: `name..ext`. - Fingerprinted URLs are served with `Cache-Control: public, max-age=31536000, immutable`. - When the file changes, its hash and URL change, so browsers and CDNs fetch the new one. See [asset](/docs/ctx/asset) and [Caching](/docs/caching). ## Documentation: /static-content # Serving Static Content in PocketPages Any file in `pb_pages` that isn't a page (`.ejs`, `.md`), a framework file (`+...`), private (`_...`) or ignored (names starting with `-` or `.`) is a static file. It's served as-is at its path, with groups removed: `pb_pages/ assets/ app.css -> /assets/app.css logo.png -> /assets/logo.png documents/ terms.pdf -> /documents/terms.pdf (marketing)/ brochure.pdf -> /brochure.pdf favicon.ico -> /favicon.ico index.ejs -> / (a page, not static) ` - The content type comes from the extension. - The `ETag` comes from the content hash; a matching `If-None-Match` gets `304`. - A static file and a page can't claim the same URL (startup error). For cache-friendly URLs, reference files through [`asset()`](/docs/asset-management). ## `pb_pages` and `pb_public` A URL nothing in `pb_pages` matches goes on to `pb_public`, so you can keep using `pb_public` for files you don't want next to your pages. ## Documentation: /secrets # Managing Secrets in PocketPages Keep API keys, passwords and other secrets in environment variables, and read them with `ctx.env(name)`. It returns `''` when the variable is unset. `// pb_pages/checkout/+page.js module.exports = { post(ctx) { const key = ctx.env('STRIPE_SECRET_KEY') if (!key) throw new Error('STRIPE_SECRET_KEY is not set') // ... }, } ` `ctx.env` reads the app's environment. Under a multitenant host's sandbox, it reads the instance's environment. ## Best practices - Never put secrets in the page. Anything a template outputs goes to the browser. ` ` - Check for required variables and fail clearly when one is missing. - Use descriptive names: `STRIPE_SECRET_KEY`, not `KEY`. - Never commit secrets to version control, and use different values in development and production. ## Setting environment variables ``` # Shell export STRIPE_SECRET_KEY=sk_test_... pocketbase serve ``` ``` # Docker ENV STRIPE_SECRET_KEY=sk_test_... ``` ## Documentation: /config # Configuration with `+config.js` Create `pb_pages/+config.js` to configure PocketPages for the app: `// pb_pages/+config.js module.exports = { plugins: ['auth', 'datastar', require('./_private/my-plugin.js')], debug: false, } ` ## Options Key Default `plugins` `[]` Built-in plugins by name, or JS plugin factories. 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. ## Plugins Each entry in `plugins` is one of: - A built-in name: `'auth'`, `'realtime'` or `'datastar'`. See [Plugins](/docs/plugins). - A JS plugin factory: a function that returns hooks. - An object with a factory and options: `{ factory, ...options }`. The object is passed to the factory as its `config`. `module.exports = { plugins: [ 'auth', 'realtime', { factory: require('./_private/banner.js'), text: 'Hello' }, ], } ` Plugin hooks run in the order the plugins are listed. See [Plugin Authoring](/docs/plugins/authoring). ## Debug `debug: true` turns on what dev mode (`--dev`) turns on: detailed error pages and `ctx.log.debug` output. See [Debugging](/docs/debugging). ## Documentation: /caching # Understanding Caching in PocketPages ## Fingerprinted assets `asset()` adds a content hash to a file's URL: ` ` - The hash is the first 12 hex characters of the file's SHA-256. - Fingerprinted URLs are served with `Cache-Control: public, max-age=31536000, immutable`, so browsers and CDNs keep them for a year. - When the file changes, the hash changes, and so does the URL. Old copies are never served for the new URL. - A path that isn't a file in `pb_pages` is returned unchanged (and not fingerprinted). ## Static files Every static file gets an `ETag` from its content hash. A request with a matching `If-None-Match` gets `304 Not Modified`. ## Templates A template is compiled once per content hash and reused. Edit a file and the next request uses the new version, in every mode. ## Routes The route table is built at startup and rebuilt whenever anything in `pb_pages` changes. A rebuild never restarts the app. ## Pages Rendered pages aren't cached. To let browsers or a CDN cache a page, set a header in a handler or middleware: ``` module.exports = (ctx, next) => { ctx.res.header('Cache-Control', 'public, max-age=60') return next() } ``` ## Documentation: /errors # Errors ## Thrown 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` or `.md` source), and the stack trimmed to `pb_pages`-relative paths. - Otherwise: a plain "Something went wrong" page. The full error always goes to the app's log. ## `ctx.httpError(status, message)` Throw `ctx.httpError()` to answer with a specific status: `// pb_pages/products/[id]/+page.js module.exports = { load(ctx) { const product = ctx.app.findFirstRecordByData('products', 'slug', ctx.params.id) if (!product) throw ctx.httpError(404, 'No such product') ctx.data.product = product }, } ` - In a page, it sends that status with a plain page showing `message`. - In a `+api.js`, it sends JSON: `{ "status": 404, "message": "No such product" }`. ## A custom error page Create `pb_pages/+error.ejs`. It renders for every `ctx.httpError` and for every `500` outside dev mode, inside the root layout, with `ctx.error = { status, message }`: ``` <% slot('title', () => { %>Error <%= ctx.error.status %><% }) %>

<%= ctx.error.status %>

<%= ctx.error.message %>

``` ## Documentation: /debugging # Debugging in PocketPages ## Dev mode Run PocketBase with `--dev`: `pocketbase serve --dev ` Dev mode turns on: - detailed error pages (message, file and line, trimmed stack; see [Errors](/docs/errors)); - `ctx.log.debug` output. ## `debug: true` To get the same in production, set `debug: true` in `+config.js`: `module.exports = { debug: true, } ` Turn it off again when you're done. Detailed error pages show error messages, file paths and stack traces to anyone who triggers an error. ## Logging `ctx.log` writes to the app's log: `ctx.log.debug('loaded product', ctx.params.id) // only in dev mode or with debug: true ctx.log.info('order placed') ctx.log.warn('stock is low') ctx.log.error('payment failed') ` See [log](/docs/ctx/log). ## Template changes Templates are cached by content hash and the route table is rebuilt on any file change, in every mode. If a change doesn't show, check the app's log for a startup error (bad file name, route conflict): the previous route table keeps serving until the error is fixed. ## Documentation: /deploying # Going Live A PocketPages app is a `pb_pages` folder. To go live, run PocketBase CE with the pages plugin on your server and put `pb_pages` next to `pb_data`, along with your other `pb_*` folders: `pb_data/ pb_hooks/ pb_migrations/ pb_public/ pb_pages/ ` Or point PocketBase at it with `--pagesDir`. ## Updating Copy the changed files into `pb_pages`. The route table is rebuilt on any change, and templates are cached by content hash, so updates show on the next request without restarting the app. If an update has a startup error (a bad name or a route conflict), it's logged with the file path and the previous route table keeps serving. ## Before you go live - Run without `--dev`, and keep `debug: false` in `+config.js`. - Set secrets as environment variables (see [Managing Secrets](/docs/secrets)). - Serve over HTTPS. Cookies set with `ctx.res.cookie` are marked `Secure` on HTTPS requests. ## Multitenant hosts Under a multitenant PocketBase CE host, every instance has its own `pb_pages`, and page code runs with the same limits as the rest of the instance's JS.