HTMX and PocketBase: One-Binary Full Stack
The Stack That Fits on One Server
Two projects that have almost nothing in common architecturally keep showing up in the same repositories. HTMX is a 51 KB browser script that lets any HTML element issue an HTTP request and swap the returned HTML into the page. PocketBase is a single Go executable that bundles SQLite, authentication, file storage, a realtime layer and an admin dashboard. Neither was designed with the other in mind. Put them together and you get a full-stack application that deploys by copying one binary and one folder of hooks onto a $5 VPS.
That is the appeal, and it is a real one. It is also, as of July 2026, a pairing that requires you to write plumbing that neither project provides for you — and PocketBase's maintainer has said in the official documentation that he does not recommend it. This guide takes that objection seriously, shows exactly which parts of it still hold, builds a complete working application anyway, and then lays out the failure modes you should know about before you commit a production system to this shape.
Everything here is pinned to the versions current on 28 July 2026:
Component | Version | Released | Notes |
|---|---|---|---|
htmx | 2.0.10 | 21 April 2026 | npm |
htmx (next) | 4.0.0-beta6 | 23 July 2026 | npm |
PocketBase | v0.39.9 | 22 July 2026 | still pre-1.0 |
PocketBase JS SDK | 0.27.0 | 24 May 2026 | only needed for the realtime bridge |
Verify these yourself before you build — the PocketBase release cadence has averaged a patch every few days, and the htmx 4.0 branch moves faster than that.
What HTMX Actually Is
The one-sentence version
HTMX removes four arbitrary restrictions from HTML: only <a> and <form> can make HTTP requests, only click and submit can trigger them, only GET and POST are available, and only the entire page can be replaced. Lift those four restrictions and you can build most application UIs without writing a client-side state layer.
A button that posts to the server and replaces itself with the response:
html
<button hx-post="/links/42/archive" hx-swap="outerHTML">
Archive
</button>The server returns an HTML fragment. HTMX puts it in the DOM. There is no JSON, no serializer, no client-side model to keep in sync, no hydration step. The server remains the single source of truth for what the interface currently looks like — the same model that ran the web before 2010, with the full-page reloads removed.
The attributes you will actually use
Ninety percent of real HTMX code uses eight attributes:
hx-get/hx-post/hx-put/hx-patch/hx-delete— issue a request to a URLhx-target— a CSS selector for where the response goes (defaults to the element itself)hx-swap— how it goes in:innerHTML(default),outerHTML,beforeend,afterbegin,delete,nonehx-trigger— what fires the request: an event name plus modifiers likechanged,delay:300ms,once,revealed,from:bodyhx-swap-oob— "out of band": mark an element in the response to be swapped somewhere else by idhx-indicator— a selector for the element to show while the request is in flighthx-confirm— a browser confirm dialog before firinghx-headers/hx-vals— extra headers or parameters, both parsed as JSON rather than evaluated
On the response side, the server can drive the client with headers: HX-Trigger fires a client-side event, HX-Redirect navigates, HX-Refresh reloads, HX-Retarget overrides the target. Requests arrive with HX-Request: true, which lets one route serve both a full page and a fragment.
Where HTMX stands right now
The current stable release is 2.0.10, published 21 April 2026, one day after 2.0.9. Minified it is 51 KB on disk and roughly 16 KB over the wire with gzip — I measured both from the jsDelivr artifact rather than quoting the marketing figure, which still says "~14k min.gz'd" from an older release.
html
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"
integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V"
crossorigin="anonymous"></script>npm downloads sit around 207,000 per week, and the repository is near 48,000 GitHub stars. The license is BSD Zero Clause — effectively public domain, no attribution required.
One correction worth making up front. A number of articles published this spring describe htmx 4.0 as released. It is not. As of today the npm latest tag still resolves to 2.0.10 and next resolves to 4.0.0-beta6 (23 July 2026). Carson Gross's own timeline says 4.0 will be marked latest in "early-2027ish", and that htmx 2.0 will be supported in perpetuity. If a tutorial tells you to install htmx 4 for a production build today, it is ahead of the project.
What PocketBase Actually Is
Four subsystems in one file
Download one executable, run ./pocketbase serve, and you have:
A database — embedded SQLite in WAL mode, with a schema editor in the dashboard and a filter language over the REST API
Authentication — password, OTP, MFA, and 15+ OAuth2 providers, with per-collection rules
File storage — local disk or any S3-compatible bucket, with thumbnails and protected-file tokens
Realtime — Server-Sent Events that push create/update/delete notifications to subscribed clients
Plus automatic TLS via Let's Encrypt, backups to local disk or S3, a rate limiter, a migration system, and an admin dashboard. There is no external dependency. The entire application state lives in a pb_data directory you can rsync.
Extending it: Go or JavaScript
PocketBase deliberately has no cloud functions. Instead you extend it in one of two ways:
As a Go framework — import it as a library, register hooks and routes, compile your own binary.
Through
pb_hooks— drop*.pb.jsfiles next to the executable. The prebuilt binary embeds the goja JavaScript engine and runs them with a prewarmed pool of 15 runtimes. On UNIX systems the process auto-restarts when a hook file changes.
The JavaScript API mirrors the Go one with camelCase names and thrown exceptions instead of returned errors: app.FindRecordById(...) in Go is $app.findRecordById(...) in JS. Ambient TypeScript declarations ship in pb_data/types.d.ts, so editor completion works if you add a triple-slash reference at the top of your file.
This is what makes the HTMX pairing possible at all. pb_hooks gives you routerAdd(), a Go html/template renderer via $template, cookie bindings, and full access to the record layer — which is to say, everything a small server-rendered app needs, inside the same process that owns the data.
The v0.39.9 state of play
PocketBase sits at roughly 60,300 GitHub stars and 3,600 forks, with a JS SDK pulling about 196,000 npm downloads a week. It is MIT licensed. The v0.39.9 release landed 22 July 2026 with fixes to Firefox range selection and the goja/regexp2 dependencies — a maintenance release in a very active line.
Three things from the official FAQ deserve to sit at the top of your evaluation, not the bottom:
It scales vertically only. The maintainer's own answer is unambiguous: one server, no fleet. The stated benchmark is 10,000+ persistent realtime connections on a €4-class Hetzner CAX11 (2 vCPU, 4 GB RAM). That is a vendor-run number on a favorable workload, so treat it as an order-of-magnitude signal rather than a capacity plan — but the shape of the claim matches what SQLite in WAL mode does well.
It is a personal project with no support promises. The FAQ states plainly that PocketBase is neither a startup nor a business, has no paid team, is developed on a volunteer basis, and makes no maintenance or support commitments beyond what already exists. Donations are not accepted. There is a public roadmap but no fixed ETAs. That is not a criticism — it is an accurate description that should feed directly into your risk assessment.
It is still pre-1.0. v0.23 was a major internal refactor that broke the Go and JSVM APIs, and the changelog explicitly walks users through migrating hooks and SDK code. Another such release is possible. Pin your version and read the changelog before upgrading.
The Objection You Should Read Before Writing Any Code
PocketBase's documentation contains a section titled "Why not htmx, Hotwire/Turbo, Unpoly, etc." It is short and it is worth taking at face value. The position, in the maintainer's words paraphrased: these tools are built for server-rendered applications, and they do not fit the JSON-first, fully stateless design of PocketBase. Using them is possible but not recommended, because the project lacks the helpers and utilities an SSR-first application needs. Four specific gaps are named:
No cookie middleware. PocketBase authenticates via an
Authorizationheader, not a session cookie.CORS and CSRF become your problem. The default CORS middleware allows all origins precisely because PocketBase is stateless and does not rely on cookies. Introduce cookies and that assumption no longer holds.
You need custom auth endpoints. There is no login form flow, only JSON auth APIs.
API rules do not apply to your routes. Collection rules — the
listRule,viewRule,createRuleexpressions you configure in the dashboard — govern the built-in JSON routes only. Anything you register withrouterAdd()enforces nothing until you write the check yourself.
The docs add that official SSR support in the form of guides and middlewares could come eventually, but that PocketBase was not designed for it, and that you may want to re-evaluate your stack.
What has changed since that guidance was written
Two of the four gaps are now considerably narrower than they were when that section was first published.
Cookies are supported in the JSVM. Earlier community answers had to build raw Set-Cookie strings by hand because there were no bindings for Go's http.Cookie. Today the JSVM exposes a Cookie class and e.setCookie(), and the request side has e.request.cookie(name). Sessions in pb_hooks are now about fifteen lines of code.
Middleware priorities are documented and stable. PocketBase's own auth-token loader runs at priority -1020. Register your cookie loader at -1019 and it runs immediately afterward, populating e.auth before anything else looks at it. That means $apis.requireAuth() — the built-in middleware — works unchanged on your cookie-authenticated routes.
The other two gaps are real and permanent-ish. CSRF protection is yours to write. And API rules genuinely do not cover custom routes — this is the single most dangerous thing about the stack, because the dashboard shows you a rule, the rule looks enforced, and on your routerAdd() route it is doing nothing at all. Every filter in this tutorial binds the owner id explicitly for that reason.
So: the documentation's advice is not wrong, but it is now closer to "you will write your own session and authorization layer" than to "this cannot be done." Whether that trade is worth it depends entirely on the size of your application.
Three Ways to Wire HTMX to PocketBase
Option A — Render inside pb_hooks
PocketBase serves the HTML. Routes registered with routerAdd() call $template.loadFiles(...).render(data) and return fragments with e.html(). One process, one binary, no network hop between the template and the database.
Best for: small-to-midsize apps, internal tools, side projects, anything where operational simplicity is the point
Cost: you write JavaScript against a goja engine with real limitations (below), and you own auth and CSRF
This is what the tutorial builds
Option B — A separate server with PocketBase as a data store
Your Go, Python, Node or Ruby app renders HTML and talks to PocketBase over HTTP as a superuser, exactly as it would to a database. PocketBase handles storage, auth records, files and backups; your app handles sessions, rendering and business logic. This is the shape the PocketBase docs themselves recommend for server-side work.
Best for: teams with an existing framework they like, or complex business logic that deserves a real language
Cost: two processes, an extra network hop per query, and the temptation to reimplement half of PocketBase
Important: a superuser client bypasses all API rules. Every authorization decision moves into your app.
Option C — Hybrid: HTMX for pages, JS SDK for realtime
Pages and interactions are server-rendered fragments; a few kilobytes of the PocketBase JS SDK run in the browser purely to receive realtime notifications, which are converted into HTMX triggers. This is the pattern that actually works for live updates, and step 12 of the tutorial builds it.
Why the naive realtime approach fails
It is worth understanding this before you try it. PocketBase's realtime API is a two-step handshake:
GET /api/realtimeopens the SSE stream and immediately sends aPB_CONNECTevent containing a client idPOST /api/realtimewith that client id and a list of subscriptions registers what you want to receive — and this is also where authorization happens, via theAuthorizationheader
The htmx SSE extension can do step 1. It cannot do step 2, because it has no way to read the client id out of the first event and issue a separate POST with it. Community threads on this go back years and the answer has always been the same. Access control matters here too: subscribing to a whole collection is checked against listRule, and subscribing to a single record against viewRule — so an unauthenticated subscription is not just a workaround, it is a data leak waiting to happen.
The workable options are a small JS bridge (Option C), or a custom SSE route in your hooks that streams your own fragments. The bridge is far less code.
Project: "Stash", a Team Link Board
What we are building
A shared link board for a small team. Members sign in, save links with a title, URL, note and tags, search the board as they type, edit entries inline, delete with confirmation, page through results, and see other members' additions appear without refreshing.
It exercises the parts of both tools that matter in real applications: cookie sessions, server-side authorization on custom routes, fragment rendering, out-of-band swaps, response-header-driven toasts, debounced search, click-to-edit, infinite scroll, realtime fan-out, CSRF defense, and a scheduled cleanup job. Roughly 400 lines of JavaScript and templates, no build step, no node_modules.
Prerequisites
The PocketBase v0.39.9 executable for your platform
A text editor with TypeScript LSP support (optional, but the ambient types are genuinely useful)
A UNIX-like OS if you want hook auto-reload — it does not work on Windows
Step 1 — Layout
stash/
├── pocketbase # the v0.39.9 executable
├── pb_migrations/
│ └── 1753660800_stash_schema.js
├── pb_hooks/
│ ├── utils.js # shared helpers (required from handlers)
│ ├── main.pb.js # middleware + page routes
│ ├── auth.pb.js # login / register / logout
│ └── links.pb.js # CRUD fragments
└── pb_public/
└── app.cssFiles load in filename sort order, so auth.pb.js runs before links.pb.js before main.pb.js. Registration order does not matter for routes, but it does for anything you do at bootstrap.
Start it once to create pb_data and the superuser:
bash
./pocketbase superuser create you@example.com "a-long-password"
./pocketbase serve --dev--dev prints full error details to the console instead of hiding them behind generic API errors. Never run it in production.
Step 2 — Schema as a migration
Create the collection in code rather than clicking through the dashboard, so the schema is versioned and reproducible.
javascript
// pb_migrations/1753660800_stash_schema.js
migrate((app) => {
const links = new Collection({
type: "base",
name: "links",
// These rules govern the built-in JSON API only.
// They do NOT apply to our custom routes.
listRule: "owner = @request.auth.id",
viewRule: "owner = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "owner = @request.auth.id",
deleteRule: "owner = @request.auth.id",
fields: [
{ type: "text", name: "title", required: true, max: 160 },
{ type: "url", name: "url", required: true },
{ type: "text", name: "note", max: 500 },
{ type: "text", name: "tags", max: 200 },
{
type: "relation",
name: "owner",
required: true,
maxSelect: 1,
cascadeDelete: true,
collectionId: app.findCollectionByNameOrId("users").id,
},
{ type: "bool", name: "archived" },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE INDEX idx_links_owner_created ON links (owner, created DESC)",
"CREATE INDEX idx_links_title ON links (title)",
],
});
app.save(links);
}, (app) => {
app.delete(app.findCollectionByNameOrId("links"));
});Restart, and the migration applies automatically inside a transaction. The indexes are not decoration: without idx_links_owner_created, every board render does a full scan, and the difference shows up around a few thousand rows.
Step 3 — The templates
$template wraps Go's html/template. Base templates declare placeholders with {{block}}; partials fill them with {{define}}. Escaping is contextual and automatic — HTML, JS, CSS and URI contexts each get the right treatment — which is a meaningful security advantage over string concatenation.
html
<!-- pb_hooks/views/layout.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Stash{{end}}</title>
<link rel="stylesheet" href="/app.css">
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"
integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V"
crossorigin="anonymous"></script>
</head>
<body hx-headers='{"X-Stash-CSRF": "{{.csrf}}"}'>
<header>
<a href="/">Stash</a>
{{if .user}}
<span>{{.user}}</span>
<button hx-post="/logout" hx-swap="none">Sign out</button>
{{end}}
</header>
<main id="main">
{{block "body" .}}{{end}}
</main>
<div id="toasts" aria-live="polite"></div>
</body>
</html>Two things are doing quiet work here. hx-headers on <body> attaches the CSRF token to every request htmx makes from anywhere in the page — this is inherited behavior in htmx 2, and it is one of the attributes whose inheritance becomes explicit in 4.0. And aria-live="polite" on the toast container means screen readers announce injected messages; without it, swapped content is silently invisible to assistive technology, which is the single most common accessibility mistake in HTMX applications.
The board page and the row fragment:
html
<!-- pb_hooks/views/board.html -->
{{define "title"}}Stash — Board{{end}}
{{define "body"}}
<form hx-post="/links"
hx-target="#link-list"
hx-swap="afterbegin"
hx-on::after-request="if(event.detail.successful) this.reset()">
<input name="title" placeholder="Title" required maxlength="160">
<input name="url" type="url" placeholder="https://..." required>
<input name="tags" placeholder="tags, comma, separated">
<textarea name="note" placeholder="Why this matters" maxlength="500"></textarea>
<button type="submit">Save link</button>
<span class="htmx-indicator">Saving…</span>
</form>
<input type="search"
name="q"
placeholder="Search the board"
hx-get="/links"
hx-trigger="input changed delay:300ms, search"
hx-target="#link-list"
hx-swap="innerHTML"
hx-indicator="#search-spinner">
<span id="search-spinner" class="htmx-indicator">Searching…</span>
<div id="link-list"
hx-get="/links"
hx-trigger="load, stash:refresh from:body">
</div>
{{end}}html
<!-- pb_hooks/views/_row.html -->
{{define "row"}}
<article class="link" id="link-{{.id}}">
<h3><a href="{{.url}}" rel="noopener noreferrer" target="_blank">{{.title}}</a></h3>
{{if .note}}<p>{{.note}}</p>{{end}}
{{if .tags}}<p class="tags">{{.tags}}</p>{{end}}
<footer>
<button hx-get="/links/{{.id}}/edit"
hx-target="#link-{{.id}}"
hx-swap="outerHTML">Edit</button>
<button hx-delete="/links/{{.id}}"
hx-target="#link-{{.id}}"
hx-swap="outerHTML swap:200ms"
hx-confirm="Delete “{{.title}}”?">Delete</button>
</footer>
</article>
{{end}}Note hx-swap="outerHTML swap:200ms" on delete. The delay gives the htmx-swapping class time to run a CSS fade before the element is removed. That is the whole animation system: two classes and a timing modifier.
Step 4 — Shared helpers, and the isolation trap
This is the PocketBase gotcha that costs people an afternoon. Every hook, route and middleware handler is serialized and executed in its own isolated context as a separate program. Variables and functions declared outside the handler are simply not visible inside it:
javascript
const APP_NAME = "Stash"
onBootstrap((e) => {
e.next()
console.log(APP_NAME) // undefined — this is not a bug
})The supported workaround is a CommonJS module loaded with require() inside each handler. Modules share a registry across runtimes, so treat them as read-only — mutating module state invites concurrency problems.
javascript
// pb_hooks/utils.js
module.exports = {
PAGE_SIZE: 20,
// Render one or more template files with the shared layout.
page(e, view, data) {
data = data || {}
data.user = e.auth ? e.auth.email() : ""
data.csrf = e.get("csrf") || ""
return $template.loadFiles(
`${__hooks}/views/layout.html`,
`${__hooks}/views/${view}.html`,
`${__hooks}/views/_row.html`,
).render(data)
},
// Render a bare fragment (no layout) — used for htmx swaps.
fragment(e, view, data) {
return $template.loadFiles(
`${__hooks}/views/${view}.html`,
).render(data || {})
},
// Convert a Record into a plain object the templates can read.
linkToMap(rec) {
return {
id: rec.id,
title: rec.getString("title"),
url: rec.getString("url"),
note: rec.getString("note"),
tags: rec.getString("tags"),
}
},
// Fetch a link and assert ownership. API rules do not run here.
ownedLink(e, id) {
const rec = e.app.findRecordById("links", id)
if (!e.auth || rec.getString("owner") !== e.auth.id) {
throw new ForbiddenError("Not your link")
}
return rec
},
sessionCookie(token, maxAgeSeconds) {
return new Cookie({
name: "stash_session",
value: token,
path: "/",
maxAge: maxAgeSeconds,
httpOnly: true,
secure: true,
sameSite: 3, // 1=Default 2=Lax 3=Strict 4=None
})
},
}ownedLink() is the most important function in this file. It is the line of defense that collection API rules would have given you for free on the JSON API and give you nothing on a custom route.
A note on sameSite: 3. Those integers are Go's http.SameSite constants: 1 is Default, 2 is Lax, 3 is Strict, 4 is None. Strict is the right choice for a self-contained app — it means the browser never sends the session cookie on a cross-site request, which removes most of the CSRF surface before you write a line of defense. If you need links from email or external sites to land users in a logged-in state, drop to Lax (2) and lean harder on the token check in step 13. And secure: true means the cookie will not be sent over plain HTTP — if you are testing on http://127.0.0.1:8090, flip it to false locally and back for deployment.
Step 5 — Cookie sessions
javascript
// pb_hooks/main.pb.js
/// <reference path="../pb_data/types.d.ts" />
// Load the auth state from our session cookie.
// Priority -1019 puts this immediately after PocketBase's own
// Authorization-header loader (-1020), so e.auth is populated
// before anything downstream — including $apis.requireAuth().
routerUse(new Middleware((e) => {
if (e.auth) {
return e.next() // header auth already won; don't override
}
try {
const cookie = e.request.cookie("stash_session")
if (cookie && cookie.value) {
e.auth = e.app.findAuthRecordByToken(cookie.value, "auth")
}
} catch (_) {
// no cookie, expired token, or bad signature — stay a guest
}
return e.next()
}, -1019))findAuthRecordByToken verifies the signature and expiry and returns the record only if both are valid, so an expired or forged cookie degrades to guest access rather than throwing.
Token lifetime is controlled by the users collection's auth-token duration setting, not by the cookie. Set the cookie maxAge to match it, or users will hold a cookie that the server has already stopped honoring.
Step 6 — Login, register, logout
javascript
// pb_hooks/auth.pb.js
/// <reference path="../pb_data/types.d.ts" />
routerAdd("POST", "/login", (e) => {
const utils = require(`${__hooks}/utils.js`)
const form = new DynamicModel({ email: "", password: "" })
e.bindBody(form)
let record
try {
record = e.app.findAuthRecordByEmail("users", form.email)
if (!record.validatePassword(form.password)) {
throw new Error("bad password")
}
} catch (_) {
// One generic message for both cases: never confirm which
// email addresses exist. This is basic enumeration defense.
e.response.header().set("HX-Retarget", "#login-error")
return e.html(200, `<p class="error">Invalid email or password.</p>`)
}
const maxAge = 7 * 24 * 60 * 60
e.setCookie(utils.sessionCookie(record.newAuthToken(), maxAge))
// Tell htmx to do a full navigation rather than swapping.
e.response.header().set("HX-Redirect", "/")
return e.noContent(204)
})
routerAdd("POST", "/register", (e) => {
const utils = require(`${__hooks}/utils.js`)
const form = new DynamicModel({ email: "", password: "" })
e.bindBody(form)
const users = e.app.findCollectionByNameOrId("users")
const user = new Record(users)
user.setEmail(form.email)
user.setPassword(form.password)
try {
e.app.save(user) // runs field validation, including password rules
} catch (err) {
e.response.header().set("HX-Retarget", "#login-error")
return e.html(200, `<p class="error">Could not create that account.</p>`)
}
const maxAge = 7 * 24 * 60 * 60
e.setCookie(utils.sessionCookie(user.newAuthToken(), maxAge))
e.response.header().set("HX-Redirect", "/")
return e.noContent(204)
})
routerAdd("POST", "/logout", (e) => {
const utils = require(`${__hooks}/utils.js`)
e.setCookie(utils.sessionCookie("", -1)) // maxAge < 0 deletes it
e.response.header().set("HX-Redirect", "/")
return e.noContent(204)
}, $apis.requireAuth())$apis.requireAuth() on the logout route is the payoff from step 5: a built-in PocketBase middleware working correctly against a cookie session it knows nothing about.
HX-Redirect is the right tool when the whole page context changes. HX-Retarget is the right tool when you want an error to land somewhere other than where the form pointed — it keeps error handling out of the happy-path markup.
Step 7 — The page route
javascript
// pb_hooks/main.pb.js (continued)
routerAdd("GET", "/", (e) => {
const utils = require(`${__hooks}/utils.js`)
if (!e.auth) {
return e.html(200, utils.page(e, "login", {}))
}
return e.html(200, utils.page(e, "board", {}))
})One route, two pages, no client-side router. If you later want the login form to appear as a modal fragment, add a check on e.request.header.get("HX-Request") and return the fragment alone instead of the full layout.
Step 8 — The list fragment, with search and pagination
javascript
// pb_hooks/links.pb.js
/// <reference path="../pb_data/types.d.ts" />
routerAdd("GET", "/links", (e) => {
const utils = require(`${__hooks}/utils.js`)
const info = e.requestInfo()
const q = (info.query["q"] || "").trim()
const page = parseInt(info.query["page"] || "0", 10) || 0
const size = utils.PAGE_SIZE
// Owner is bound from the session, never from the request.
const params = { owner: e.auth.id }
let filter = "owner = {:owner} && archived = false"
if (q) {
filter += " && (title ~ {:q} || note ~ {:q} || tags ~ {:q})"
params.q = q
}
const records = e.app.findRecordsByFilter(
"links", filter, "-created", size + 1, page * size, params,
)
const hasMore = records.length > size
const rows = []
for (let i = 0; i < Math.min(records.length, size); i++) {
rows.push(utils.linkToMap(records[i]))
}
return e.html(200, utils.fragment(e, "_list", {
rows: rows,
hasMore: hasMore,
nextPage: page + 1,
q: q,
empty: rows.length === 0 && page === 0,
}))
}, $apis.requireAuth())Three details that matter more than they look:
Fetch size + 1, render size. That is how you know whether a "load more" control is needed without running a second COUNT query. On SQLite the saving is small; the habit is worth keeping.
Bind everything. {:q} and {:owner} are parameter placeholders. The PocketBase docs are explicit that the params argument is how you bind untrusted input, and string-concatenating a search term into a filter expression is the filter-injection equivalent of SQL injection.
archived = false is in the filter, not in JavaScript. Filtering after fetching breaks pagination in ways that are annoying to debug.
html
<!-- pb_hooks/views/_list.html -->
{{define "_list"}}
{{if .empty}}
<p class="empty">Nothing saved yet.</p>
{{end}}
{{range .rows}}
<article class="link" id="link-{{.id}}">
<h3><a href="{{.url}}" rel="noopener noreferrer" target="_blank">{{.title}}</a></h3>
{{if .note}}<p>{{.note}}</p>{{end}}
{{if .tags}}<p class="tags">{{.tags}}</p>{{end}}
<footer>
<button hx-get="/links/{{.id}}/edit"
hx-target="#link-{{.id}}" hx-swap="outerHTML">Edit</button>
<button hx-delete="/links/{{.id}}"
hx-target="#link-{{.id}}" hx-swap="outerHTML swap:200ms"
hx-confirm="Delete this link?">Delete</button>
</footer>
</article>
{{end}}
{{if .hasMore}}
<div hx-get="/links?page={{.nextPage}}&q={{.q}}"
hx-trigger="revealed"
hx-swap="outerHTML">
Loading more…
</div>
{{end}}
{{end}}That hx-trigger="revealed" block is infinite scroll in five lines. The sentinel requests the next page when it scrolls into view, and replaces itself with the next batch — which contains its own sentinel, or does not, and the sequence ends.
Step 9 — Create, with an out-of-band toast
javascript
routerAdd("POST", "/links", (e) => {
const utils = require(`${__hooks}/utils.js`)
const form = new DynamicModel({ title: "", url: "", note: "", tags: "" })
e.bindBody(form)
const collection = e.app.findCollectionByNameOrId("links")
const rec = new Record(collection)
rec.set("title", form.title)
rec.set("url", form.url)
rec.set("note", form.note)
rec.set("tags", form.tags)
rec.set("owner", e.auth.id) // from the session, not the form
rec.set("archived", false)
try {
e.app.save(rec) // validates url, max lengths, required
} catch (err) {
return e.html(200,
`<div id="toasts" hx-swap-oob="true"><p class="error">Check the title and URL.</p></div>`)
}
const row = utils.fragment(e, "_row", utils.linkToMap(rec))
const toast = `<div id="toasts" hx-swap-oob="true"><p class="ok">Saved.</p></div>`
return e.html(200, row + toast)
}, $apis.requireAuth())The response carries two things: the new row, which the form's hx-target="#link-list" hx-swap="afterbegin" places at the top of the list, and a hx-swap-oob="true" div that htmx pulls out of the response and swaps into #toasts by id. One request, two DOM updates, no client-side code.
Setting owner from e.auth.id rather than from the form is the same defensive point as ownedLink(), from the other direction. A form field named owner would otherwise be an authorization bypass.
If you prefer event-driven notifications over markup, the alternative is a response header:
javascript
e.response.header().set("HX-Trigger",
JSON.stringify({ "stash:toast": { level: "ok", message: "Saved." } }))htmx fires stash:toast on the triggering element with that payload in event.detail. It is cleaner if you already have a toast component; the OOB swap is cleaner if you do not want any JavaScript at all.
Step 10 — Click to edit
Two routes: one returns a form in place of the row, one applies the change and returns the row again.
javascript
routerAdd("GET", "/links/{id}/edit", (e) => {
const utils = require(`${__hooks}/utils.js`)
const rec = utils.ownedLink(e, e.request.pathValue("id"))
return e.html(200, utils.fragment(e, "_edit", utils.linkToMap(rec)))
}, $apis.requireAuth())
routerAdd("PUT", "/links/{id}", (e) => {
const utils = require(`${__hooks}/utils.js`)
const rec = utils.ownedLink(e, e.request.pathValue("id"))
const form = new DynamicModel({ title: "", url: "", note: "", tags: "" })
e.bindBody(form)
rec.set("title", form.title)
rec.set("url", form.url)
rec.set("note", form.note)
rec.set("tags", form.tags)
e.app.save(rec)
return e.html(200, utils.fragment(e, "_row", utils.linkToMap(rec)))
}, $apis.requireAuth())
routerAdd("DELETE", "/links/{id}", (e) => {
const utils = require(`${__hooks}/utils.js`)
e.app.delete(utils.ownedLink(e, e.request.pathValue("id")))
return e.html(200, "") // empty response replaces the row with nothing
}, $apis.requireAuth())html
<!-- pb_hooks/views/_edit.html -->
{{define "_edit"}}
<form class="link editing" id="link-{{.id}}"
hx-put="/links/{{.id}}"
hx-target="this"
hx-swap="outerHTML">
<input name="title" value="{{.title}}" required maxlength="160">
<input name="url" type="url" value="{{.url}}" required>
<input name="tags" value="{{.tags}}">
<textarea name="note" maxlength="500">{{.note}}</textarea>
<button type="submit">Save</button>
<button type="button"
hx-get="/links/{{.id}}"
hx-target="#link-{{.id}}"
hx-swap="outerHTML">Cancel</button>
</form>
{{end}}Cancel is a GET that re-renders the row — you would add a matching GET /links/{id} route that calls ownedLink and returns _row. The pattern is symmetric: every UI state is a URL that returns the HTML for that state.
hx-target="this" on the form means the whole form is replaced by the returned row, so the edit state collapses back to the display state in one swap.
Step 11 — Error handling, and a default that surprises people
By default, htmx 2 does not swap responses with 4xx or 5xx status codes. It fires htmx:responseError and leaves the DOM alone. If your route throws, the user sees nothing happen.
PocketBase's global error handler converts any thrown error into a generic API error to avoid leaking internals — the real message goes to Dashboard > Logs, or to the console under --dev. So a thrown ForbiddenError produces a 403 with a JSON body that htmx silently discards. Correct security behavior, confusing user experience.
Three ways to handle it, in ascending order of effort:
Return 200 with error markup. What the login route above does. Blunt, but the user sees the message.
Configure response handling. htmx 2 lets you declare which status codes should swap:
html
<meta name="htmx-config" content='{"responseHandling":[
{"code":"204","swap":false},
{"code":"[23]..","swap":true},
{"code":"422","swap":true,"target":"#form-errors"},
{"code":"[45]..","swap":false,"error":true}
]}'>Listen for the event. htmx:responseError on body gives you one place to render a toast for every failure in the application.
This behavior flips in htmx 4.0: error responses will swap by default, with per-status targeting via a new status selector syntax. If you write your error handling around the 2.x default today, note it as a migration item.
Step 12 — Realtime, done the way that works
PocketBase already broadcasts every create, update and delete over SSE. The obstacle, as covered above, is that the subscription handshake needs a second POST that the htmx SSE extension cannot make. So we let the PocketBase JS SDK own the socket and let htmx own the DOM.
First, a route that hands the browser a token it can use:
javascript
routerAdd("GET", "/rt-token", (e) => {
return e.json(200, { token: e.auth.newAuthToken() })
}, $apis.requireAuth())Then roughly fifteen lines in the layout:
html
<script type="module">
import PocketBase from "https://cdn.jsdelivr.net/npm/pocketbase@0.27.0/+esm";
const res = await fetch("/rt-token");
if (res.ok) {
const pb = new PocketBase(window.location.origin);
pb.authStore.save((await res.json()).token, null);
await pb.collection("links").subscribe("*", () => {
document.body.dispatchEvent(new Event("stash:refresh"));
});
}
</script>The list element already listens: hx-trigger="load, stash:refresh from:body". A change from any session dispatches the event, htmx re-requests the fragment, and the board updates. Subscription access control is PocketBase's: subscribing to the collection is checked against listRule, so users only receive events for records they could have listed.
Be honest about the trade you just made. That token is in JavaScript memory, which means it is reachable by any XSS on the page — the exact thing the HttpOnly session cookie was protecting against. Mitigations, roughly in order of value:
Keep the realtime token short-lived and separate from the session token (
newStaticAuthToken(duration)issues a non-renewable one)Serve a strict Content-Security-Policy, which is far more valuable than the token handling itself
Skip realtime entirely and poll:
hx-trigger="every 30s"on the list is one attribute, needs no token, and is genuinely fine for a team of twelve
Also note that the naive refresh above reloads page 0 and drops the current search term. Fixing that properly means having the event carry the changed record id and doing a targeted OOB swap — more code, better behavior. Start with the blunt version and measure whether anyone notices.
Step 13 — CSRF and origin checks
Cookies reintroduce CSRF, and PocketBase's default CORS policy allows all origins because the stateless design made that safe. Neither assumption survives session cookies, so add the checks yourself.
javascript
// pb_hooks/main.pb.js (continued)
routerUse(new Middleware((e) => {
const method = e.request.method
const safe = method === "GET" || method === "HEAD" || method === "OPTIONS"
if (!safe) {
// 1. Same-origin check via Fetch metadata.
const site = e.request.header.get("Sec-Fetch-Site")
if (site && site !== "same-origin" && site !== "none") {
throw new ForbiddenError("Cross-site request rejected")
}
// 2. Double-submit token: header must match the cookie.
let cookieToken = ""
try {
const c = e.request.cookie("stash_csrf")
cookieToken = c ? c.value : ""
} catch (_) {}
const headerToken = e.request.header.get("X-Stash-CSRF")
if (!cookieToken || cookieToken !== headerToken) {
throw new ForbiddenError("Invalid CSRF token")
}
}
// Issue a token for this session if there isn't one.
let token = ""
try {
const c = e.request.cookie("stash_csrf")
token = c ? c.value : ""
} catch (_) {}
if (!token) {
token = $security.randomString(32)
e.setCookie(new Cookie({
name: "stash_csrf", value: token, path: "/",
maxAge: 7 * 24 * 60 * 60,
httpOnly: false, // the page must read it to echo it back
secure: true, sameSite: 3,
}))
}
e.set("csrf", token) // utils.page() reads this
return e.next()
}, -500))Three layers, because each covers the others' gaps: SameSite=Strict on the session cookie stops the browser sending credentials cross-site at all; Sec-Fetch-Site catches what older or unusual clients let through; the double-submit token covers the case where an attacker can make same-site requests but cannot read your cookies. $security.randomString() uses the Go crypto bindings — do not substitute Math.random(), which in goja is not cryptographically secure and is also slower than the binding.
Also lock down origins at the process level for production: ./pocketbase serve --origins="https://stash.example.com".
Step 14 — A scheduled cleanup
javascript
// pb_hooks/main.pb.js (continued)
cronAdd("purgeArchived", "0 3 * * *", () => {
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString().replace("T", " ").substring(0, 19)
const stale = $app.findRecordsByFilter(
"links", "archived = true && updated < {:cutoff}", "", 500, 0,
{ cutoff: cutoff },
)
$app.runInTransaction((txApp) => {
for (const rec of stale) {
txApp.delete(rec)
}
})
$app.logger().info("purged archived links", "count", stale.length)
})Batch the deletes into one transaction — SQLite allows a single writer at a time, so 500 individual writes is 500 lock acquisitions. Inside runInTransaction, always use the txApp argument rather than the outer $app, or you can deadlock against your own transaction. And cap the batch: an unbounded delete on a large table blocks writers for the duration.
Step 15 — Run it
bash
./pocketbase serve --devOpen http://127.0.0.1:8090, register, save a few links, open a second browser and watch them appear. Hook files reload automatically on save (UNIX only).
For production, the deployment is genuinely one binary and one rsync:
bash
rsync -avz -e ssh ./stash/ root@YOUR_SERVER:/root/stash
ssh root@YOUR_SERVER
/root/stash/pocketbase serve stash.example.comPassing a domain issues a Let's Encrypt certificate automatically. Non-root users need setcap 'cap_net_bind_service=+ep' on the binary to bind ports 80 and 443. A systemd unit with Restart=always and LimitNOFILE=4096 turns it into a managed service; raise the file-descriptor limit if you expect many concurrent realtime connections, since each one consumes a descriptor.
Behind a reverse proxy, set the trusted-proxy headers in PocketBase settings so realIP() returns the visitor rather than the proxy — the rate limiter depends on it. A minimal Caddy config:
stash.example.com {
request_body { max_size 10MB }
reverse_proxy 127.0.0.1:8090 {
transport http { read_timeout 360s }
}
}The long read timeout matters: realtime SSE connections are long-lived and a default 60-second proxy timeout will cut them repeatedly.
For Docker, there is no official image, but the documented Dockerfile pattern is an Alpine base that unzips a pinned release. Mount a volume at /pb/pb_data or you will lose everything on the first container restart — this is the single most common PocketBase deployment mistake.
What This Stack Actually Buys You
One deployable unit
The finished application is a binary, a hooks directory, a migrations directory, and a data directory. No container orchestration, no connection pooler, no separate frontend build, no node_modules, no CI step that produces a bundle. Backup is copying pb_data. Rollback is copying it back. For a two-person team this eliminates an entire category of work.
No state synchronization
The largest ongoing cost in a typical SPA is not writing components — it is keeping client state consistent with server state. Cache invalidation, optimistic updates that need rolling back, stale reads after a mutation, a store shaped differently from the database. Server-rendered fragments delete that category. There is one copy of the truth and the server sends you a picture of it.
One language, one mental model
Business logic, authorization and rendering all live in the same file, in the same language, with direct access to the record layer. No DTOs, no serialization boundary, no duplicated validation. When authorization is three lines from the query it protects, it is much harder to forget.
Small payloads and fast first paint
The client ships 51 KB of htmx (16 KB gzipped) and whatever CSS you write. There is no framework runtime, no hydration pass, no client-side router bundle. First paint is server-rendered HTML. On low-end phones and poor networks — which is most of the world — this difference is large and not subtle.
It degrades gracefully
Every interaction is an HTTP request to a URL that returns HTML. If htmx fails to load, forms still submit and links still navigate, provided you wrote them as forms and links. That is a resilience property SPA architectures do not have.
Genuine cost floor
A single small VPS runs this. There is no managed database bill, no per-seat auth service, no function invocation charges, no egress surprises from a serverless platform. For applications with a few hundred users, the difference between this and a managed-everything stack is often the difference between a hobby that pays for itself and one that does not.
Where It Breaks Down
This section is longer than the last one on purpose. Most writing about this stack stops at the advantages.
HTMX limitations
Every interaction costs a round trip. A 200 ms network latency is invisible on a button click and unusable on a keystroke. Anything with per-character or per-pixel feedback — a collaborative document, a spreadsheet, a drawing canvas, a video editor, a complex drag-and-drop board — is client-application territory, and pretending otherwise is how you ship something slow. HTMX advocates generally agree on this; the honest framing is that it covers CRUD-shaped interfaces very well and interactive canvases not at all.
Offline is not a story. No service worker integration, no local cache, no optimistic queue. Lose the network and the application stops.
Implicit inheritance is a known design regret. Carson Gross has said publicly that implicit attribute inheritance was the biggest mistake in htmx 1.0 and 2.0 — "powerful and maddening" — which is why 4.0 makes it explicit. In 2.x, a hx-target on an ancestor silently changes the behavior of a descendant added months later. Grep for inherited attributes when debugging weird swaps.
Testing shifts to integration. Component tests do not exist because there are no components. Your tests assert on HTML fragments returned from routes, which means they are coupled to markup structure and break on cosmetic changes. This is a real cost that teams underestimate, and it is the most common source of "we tried HTMX and it got messy" reports.
No component ecosystem. No date pickers, no data grids, no combo boxes with keyboard-accessible listbox semantics. You write them, or you pull in Alpine.js or web components, and now you have a client-side library after all. The htmx docs themselves recommend Alpine for anything beyond light scripting.
Accessibility becomes manual. Swapped content does not announce itself. Focus is not managed across swaps. If a swap removes the focused element, focus falls to <body> and keyboard users lose their place. aria-live regions and explicit focus management are your job, every time.
It fights other frameworks. If part of your product is React, HTMX and React will contend over the same DOM subtree. Practitioners who have tried both in one codebase report exactly this — they compete for control of state and element lifecycle, and the bridges are unsatisfying.
PocketBase limitations
Vertical scaling only. One server. No read replicas, no clustering, no multi-region. The mitigation for durability is Litestream-style streaming replication, which the FAQ recommends and which gives you disaster recovery, not horizontal capacity. If your growth plan requires a second application server, this is the wrong foundation.
One writer at a time. SQLite in WAL mode gives excellent concurrent reads and exactly one concurrent write. Write-heavy workloads — event ingestion, analytics, high-frequency counters — will queue. Read-heavy workloads are where SQLite outperforms client-server databases, and PocketBase is optimized for that shape.
SQLite or nothing. No Postgres, no MySQL, and the FAQ says there are no plans. If your organization has a mandate for a specific database engine, the conversation ends here.
No cloud functions, no managed anything. You self-host. There is no official hosting, no official Docker image, no support contract. Patching, monitoring, uptime and backup verification are yours.
No built-in import/export. There are no first-party data migration helpers, only community suggestions. Plan your exit path before you need it.
Settings are plaintext by default. SMTP passwords and S3 credentials sit in the database as JSON unless you start the process with --encryptionEnv. There is no data-at-rest encryption for records themselves; disk-level encryption is the answer for regulated workloads.
The JS engine is not Node. goja implements most of ES6 but is not fully spec-compliant. There is no setTimeout or setInterval — no concurrent execution inside a handler at all. No fetch, no fs, no Buffer. Only CommonJS modules; ESM needs a bundler first. JSON field values require get() and set() accessors rather than plain property access. Heavy computation in pure JavaScript degrades badly, so use the Go bindings ($security.randomString) instead of hand-rolling. And every handler is an isolated program, so the require() dance from step 4 is not optional.
Stack traces lie. Because handlers are serialized before execution, line numbers in errors are frequently wrong. Budget extra debugging time.
Pre-1.0, one maintainer, no promises. v0.23 broke the API surface substantially. Another breaking release is possible. The FAQ is explicit that there is no team, no business, no support commitment, and no fixed roadmap dates. The project has been remarkably well maintained for years — but "well maintained by one volunteer" is a different risk profile from "backed by a company," and it should be written into your decision, not discovered later.
Problems that belong to the combination
API rules do not protect custom routes. Worth repeating because it is the one that produces actual breaches. You configure listRule: "owner = @request.auth.id" in the dashboard, the UI shows it as active, and your routerAdd("GET", "/links") handler ignores it completely. Every custom route needs its own ownership check. There is no framework-level safety net.
Two authentication systems in one process. The built-in JSON API authenticates via Authorization header; your pages authenticate via cookie. They coexist, but you now have two paths to the same records with different session semantics — and the JSON API remains publicly reachable at /api/collections/links/records whether or not you use it. Set your API rules correctly even if you never call those endpoints.
Realtime requires a bridge or a compromise. Covered above. Either you ship the JS SDK and accept a token in JavaScript memory, or you poll, or you write a custom SSE route. There is no clean htmx-native path.
You are ahead of the documentation. PocketBase's docs do not recommend this pairing. When something breaks, the answer will be in a GitHub discussion thread from two years ago, possibly written against a pre-v0.23 API. The community is small and there is no vendor to call.
When to Reach for This — and When Not To
Good fit:
Internal tools, admin panels, dashboards for a team that fits in a room
Content-shaped applications: link boards, wikis, CRMs, booking systems, inventory, issue trackers
Side projects and MVPs where deployment friction is the thing most likely to kill the project
Small SaaS with a predictable user base and read-heavy access patterns
Anyone who has been burned by a frontend build pipeline and wants to feel the relief
Poor fit:
Anything requiring per-keystroke or per-frame client feedback
Applications that must scale horizontally, or that have a hard multi-region requirement
Write-heavy or event-ingestion workloads
Organizations with a mandated database engine or a formal vendor-support requirement
Teams with a strict frontend/backend split — this stack collapses that boundary by design, and that is an organizational change as much as a technical one
Products with a mobile app that also needs the backend: PocketBase is excellent for that, but then you want the JSON API and an SPA, which is precisely the shape the docs recommend
Reasonable middle ground: use PocketBase's JSON API and SDK for the primary product exactly as designed, and use pb_hooks with htmx for the admin panel or internal dashboard that sits alongside it. Same binary, same data, no new infrastructure, and the SSR compromises stay confined to a surface where they cost little.
The htmx 4.0 Migration, Sketched
htmx 4.0 is in beta (4.0.0-beta6, 23 July 2026), targeted for latest in early 2027. htmx 2.0 will be supported indefinitely, so there is no forced march. But if you are writing code today, knowing what changes is worth ten minutes:
fetch()replacesXMLHttpRequest. Mostly internal, but the event model changes because the two APIs differ. Event names move to ahtmx:<phase>:<system>convention —htmx:before:requestrather thanhtmx:beforeRequest.Inheritance becomes explicit.
hx-target:inherited="#output"on the ancestor, or nothing inherits. A config flag restores the old behavior. In this tutorial, thehx-headerson<body>is the attribute that would need it.History stops snapshotting the DOM. 4.0 issues a network request for restored content instead of caching serialized DOM in session storage. This is 2.x's cache-miss path, which was already the more reliable one, and it removes a class of history bugs and a storage-based security concern.
Error responses swap by default, with per-status targeting — so your 404 and 500 responses need to return valid swap content.
Attribute renames:
hx-disablebecomeshx-ignore, andhx-disabled-eltbecomeshx-disable. Easy to grep, easy to miss.New in core: streaming responses, SSE folded back into core,
morphInner/morphOuterswaps via idiomorph,<htmx-partial>elements, a view-transition queue, an optimistic-update extension.The escape hatch: an
htmx-2-compatextension restores implicit inheritance, old event names and previous error-swapping defaults during the transition.
The practical advice is unchanged from the project's own: build on 2.0.10 now, keep the compat extension in mind, and revisit when 4.0 is marked latest.
Performance and Cost, With Caveats
Measured facts, in descending order of confidence:
htmx 2.0.10 minified is 51 KB on disk, about 16 KB gzipped — verified against the jsDelivr artifact
PocketBase is a single static binary with no runtime dependencies
htmx pulls roughly 207,000 npm downloads a week; the PocketBase JS SDK about 196,000
The number you will see quoted most — that PocketBase handles 10,000+ persistent realtime connections on a ~€4 Hetzner CAX11 — comes from the project's own FAQ and benchmarks repository. It is a maintainer-run figure on a chosen workload. That does not make it wrong; SQLite in WAL mode plus Go's networking genuinely does that class of work well. It does mean you should not translate it into a capacity plan for your workload without measuring your workload.
Be equally skeptical of comparative benchmarks in the other direction. The blog-post genre of "HTMX versus Next.js requests per second on a $5 VPS" tends to compare a template render against a full framework's SSR path, on synthetic routes, with no database contention. The architectural claim underneath is sound — server-rendered fragments do less work than hydration plus reconciliation plus a client router — but the specific multipliers are marketing more often than measurement.
The cost story is more durable than the throughput story. One VPS, no managed database, no auth vendor, no function invocations. Predictable, small, and it does not change shape as traffic grows within a single machine.
Production Checklist
Before you point a domain at this:
✅ Pin your versions — the htmx CDN URL with an SRI hash, and a specific PocketBase release, not "latest"
✅ Every custom route checks ownership — API rules do not apply; grep your handlers for the check
✅ Enable the rate limiter — Dashboard > Settings > Application; the built-in limiter covers auth and record endpoints
✅ Whitelist superuser IPs — added in v0.38.0; even a stolen superuser token is useless from an unlisted address
✅ Enable MFA on _superusers — email OTP as a second factor on the admin account
✅ Configure real SMTP — the default Unix sendmail will land your password resets in spam
✅ Set --origins to your domain, since the default allows everything
✅ --encryptionEnv if SMTP or S3 credentials live in settings
✅ Verify backups restore — scheduled ZIP snapshots to S3 are built in, but a backup you have never restored is a hypothesis. Note that ZIP generation puts the app in read-only mode, so above ~2 GB of pb_data you want the sqlite3 .backup plus rsync approach instead
✅ Raise LimitNOFILE if you use realtime connections
✅ Set GOMEMLIMIT on small instances to avoid OOM kills
✅ Ship a Content-Security-Policy — it is worth more than any token-handling scheme
✅ Drop --dev — it prints internal error details
Where This Fits in a 2026 Toolkit
It is worth being clear about what kind of thing this is. HTMX plus PocketBase is not a general-purpose replacement for the JavaScript ecosystem, and the people who built both tools would tell you so — one of them explicitly does, in his own documentation. It is a very good answer to a specific question: what is the least infrastructure I can use to run a real, multi-user, database-backed application that I control?
The answer, right now, is one binary, one directory, and 51 KB of JavaScript.
That is also worth setting next to the neighboring option. A large share of what gets built as a "web app" has no per-user state at all — a marketing site, documentation, a portfolio, a landing page. Those want static files on a CDN, and platforms like www.dplooy.com exist precisely so that shape never needs a server process, a database, or a session layer. The stack in this article earns its complexity the moment you have accounts, permissions and data that changes; before that point, it is overhead. Knowing which side of that line your project sits on is most of the architecture decision.
If you do build on it, the three things that will save you the most pain are the ones easiest to skip: check ownership on every custom route, pin your versions, and read the PocketBase changelog before every upgrade. The rest is just HTML over the wire — which, five years into htmx and four into PocketBase, is turning out to be a more durable idea than most of what shipped alongside it.