20 KiB
name, description, triggers
| name | description | triggers | |||||
|---|---|---|---|---|---|---|---|
| firebase-to-pocketbase-migration | Migrate a Firebase web app to self-hosted PocketBase — build a Firebase-compatible adapter, swap imports, and deploy with same-origin nginx proxy. |
|
Firebase → PocketBase Migration
Replace Firebase (Auth + Firestore) with PocketBase without rewriting the app. Build a thin compatibility adapter, swap one import per file, and deploy behind same-origin nginx so the PocketBase JS SDK works without CORS.
Strategy: Adapter, Don't Rewrite
Firebase apps import from firebase.js (or CDN) and call signInWithEmailAndPassword(auth, ...), collection(db, 'name'), addDoc(ref, data), getDocs(query(...)), etc. PocketBase has a different API surface.
The adapter (pocketbase.js) exports auth and db objects that mirror the Firebase surface, backed by PocketBase under the hood. All 13+ app files keep their existing collection(), query(), where(), getDocs(), addDoc(), etc. calls unchanged.
Step 1: Map the Firestore Data Model
Search for all Firestore references:
grep -rn "collection(db," *.js shared/*.js
grep -rn "gstatic.com/firebasejs" *.js shared/*.js
Identify:
- Top-level collections (e.g.,
repairOrders,appointments) - Sub-collections (e.g.,
users/{uid}/services,users/{uid}/customers) - Dynamic imports (
await import("...firebase-firestore.js")) - Auth functions used (
signOut,onAuthStateChanged,updateProfile,sendPasswordResetEmail)
Step 2: Flatten Sub-Collections
PocketBase has no native sub-collections. Flatten them into top-level collections with a userId field:
| Firestore | PocketBase Collection | Key Field |
|---|---|---|
repairOrders |
repairOrders |
userId |
users/{uid}/services |
services |
userId |
users/{uid}/customers |
customers |
userId |
users/{uid}/settings/{docName} |
settings |
userId + name |
The adapter handles the path translation transparently — app code still writes collection(db, 'users', uid, 'services').
Step 3: Create PocketBase Collections
Use the Admin API to create collections before writing any data. Create with empty rules first, then PATCH rules after the schema exists — rules that reference fields fail validation if the field doesn't exist yet.
v0.22 and earlier (uses schema key)
api("POST", "/api/collections", {
"name": "repairOrders", "type": "base",
"schema": [{"name": "userId", "type": "text", "required": True}],
"listRule": "", "viewRule": "", "createRule": "",
"updateRule": "", "deleteRule": ""
}, token)
v0.23+ (uses fields key)
api("POST", "/api/collections", {
"name": "repairOrders", "type": "base",
"fields": [{"name": "userId", "type": "text", "required": True}],
"listRule": "", "viewRule": "", "createRule": "",
"updateRule": "", "deleteRule": ""
}, token)
To check your PocketBase version: docker exec pocketbase /usr/local/bin/pocketbase --version
Two-step: schema first, rules second
# Step 1: Set schema with empty rules
api("PATCH", f"/api/collections/{col_id}", {
"fields": [...all fields including system id field...],
"listRule": "", "viewRule": "", "createRule": "",
"updateRule": "", "deleteRule": ""
}, token)
# Step 2: Apply rules now that fields exist
api("PATCH", f"/api/collections/{col_id}", {
"listRule": "userId = @request.auth.id",
"viewRule": "userId = @request.auth.id",
"createRule": "@request.auth.id != ''",
"updateRule": "userId = @request.auth.id",
"deleteRule": "userId = @request.auth.id",
}, token)
⚠️ You MUST include the system id field (with its existing properties) when PATCHing fields in v0.23+, or the update may be rejected. Fetch the collection first to get the existing field definitions, then merge new fields into them.
Define ALL fields, not just userId
Firebase/Firestore is schemaless — you can add any field to a document dynamically. PocketBase is strictly typed — any field not defined in the collection schema is silently dropped on write (HTTP 200 is returned but the data is not stored). You MUST define every field the app writes before any data operations.
Use references/collection-schema-setup.py as a template — it defines complete schemas for all common Firebase collection types and handles the two-step schema+rules process.
Create one collection per top-level name: repairOrders, appointments, quotes, tasks, services, customers, settings.
Step 4: Build the Adapter
Use templates/pocketbase-adapter.js as a starting point. The adapter handles Auth + Firestore CRUD with PocketBase underneath. Also see templates/same-origin-nginx.conf for the nginx config, references/create-collections.py for the original collection creation script (v0.22), and references/collection-schema-setup.py for the complete field schema definitions with v0.23+ two-step process.
⚠️ Critical compatibility requirements for getDoc, getDocs, addDoc, and onSnapshot are documented in references/adapter-firestore-compat-patterns.md. Read this before writing or debugging the adapter — the return shapes must match the Firestore SDK exactly or every app file silently breaks.
Auth surface (Firebase-compatible)
auth.currentUser→{ uid, email, displayName }auth.onAuthStateChanged(callback)→ fires on change + returns unsubscribesignInWithEmailAndPassword(auth, email, password)→ PocketBaseauthWithPassword()createUserWithEmailAndPassword(auth, email, password)→ PocketBasecreate()+authWithPassword()signOut(auth)→pb.authStore.clear()sendPasswordResetEmail(auth, email)→ PocketBaserequestPasswordReset()updateProfile(user, {displayName})→ PocketBaseupdate()
Firestore surface
collection(db, name)/collection(db, 'users', uid, 'sub')— top-level and sub-collectiondoc(db, 'col', id)/doc(db, 'users', uid, 'settings', name)— top-level and nestedquery(ref, where(field, op, val), orderBy(field, dir))— compiled to PocketBase filter stringsgetDocs(query)— must return Firestore-compatible snapshot shape:{docs: [...], size: N, empty: bool, forEach(cb)}. Not a raw array — every consumer calls.docs.map(),.empty,.size, or.forEach().getDoc(docRef)→pb.collection(name).getOne(id). ⚠️ Must returnexistsas a function (exists: () => true/exists: () => false), not a boolean. Firestore API calls.exists()everywhere.addDoc(ref, data)→pb.collection(name).create(data). ⚠️ Must injectuserIdfrom sub-collection ref. When app code writescollection(db, 'users', uid, 'services'), the adapter tracks_userIdon the ref butcreate()discards it. Always:if (ref._userId && !data.userId) { data.userId = ref._userId; }before callingcreate().updateDoc(docRef, data)→pb.collection(name).update(id, data)deleteDoc(docRef)→pb.collection(name).delete(id)onSnapshot(queryOrDoc, callback)— ⚠️ Must pass snapshot shape to callback. A baregetDocs(query).then(docs => callback({docs}))is wrong. Wrap increateQuerySnapshot(docs)that includessize,empty,forEach. Reference the same helper used bygetDocs.serverTimestamp()→new Date().toISOString()Timestampclass — wraps Date for.toDate()calls on existing timestamp fields
Query operator mapping
| Firebase | PocketBase Filter |
|---|---|
where('field', '==', val) |
field = "val" |
where('field', 'in', [a,b]) |
(field ?= "a" || field ?= "b") |
orderBy('field', 'desc') |
-field sort parameter |
Data conversion
- To PB: Nested objects get
JSON.stringify()'d.undefinedvalues dropped. Dates → ISO strings. - From PB: ISO strings on known timestamp fields →
Timestampobjects. JSON strings in object-like fields → parsed back.
Step 5: Swap All Imports
Replace Firebase CDN imports with the adapter in one sweep:
# Static imports
find . -name "*.js" -exec sed -i 's|from "./firebase.js"|from "./pocketbase.js"|g' {} +
find . -name "*.js" -exec sed -i 's|from "https://www\.gstatic\.com/firebasejs/[^"]*firebase-auth\.js"|from "./pocketbase.js"|g' {} +
find . -name "*.js" -exec sed -i 's|from "https://www\.gstatic\.com/firebasejs/[^"]*firebase-firestore\.js"|from "./pocketbase.js"|g' {} +
# Dynamic imports
find . -name "*.js" -exec sed -i 's|await import("https://www\.gstatic\.com/firebasejs/[^"]*firebase-auth\.js")|await import("./pocketbase.js")|g' {} +
find . -name "*.js" -exec sed -i 's|await import("https://www\.gstatic\.com/firebasejs/[^"]*firebase-firestore\.js")|await import("./pocketbase.js")|g' {} +
# Also check login.html for inline module scripts
# Also check any single-quoted import() calls
Verify zero Firebase references remain:
grep -rn 'gstatic.com/firebasejs' *.js shared/*.js *.html
Step 6: Same-Origin nginx (No CORS)
PocketBase SDK uses window.location.origin. Serve the static app AND proxy /api/ to PocketBase from the same nginx server block:
Use templates/same-origin-nginx.conf as the template. Key points:
root /path/to/app;serves static fileslocation /api/ { proxy_pass http://127.0.0.1:8091; }proxies PocketBaselocation /_/ { proxy_pass ... }for admin UI access- SPA fallback:
try_files $uri $uri/ /index.html; - WebSocket headers for realtime:
Upgrade,Connection,proxy_read_timeout 86400
This avoids CORS entirely — PocketBase calls go to /api/collections/users/auth-with-password on the same origin.
Pitfalls
-
onAuthStateChangedmust be exported as a standalone function. Many files importimport { onAuthStateChanged } from "./pocketbase.js"and callonAuthStateChanged(auth, callback). If you only export it asauth.onAuthStateChanged(a method on the auth object), those imports resolve toundefinedand the auth check silently does nothing — user sees the app without any auth redirect. Always add a standalone wrapper:function onAuthStateChanged(authRef, callback) { return auth.onAuthStateChanged(callback); }and export it alongside the other functions. -
Settings pages need
updatePassword,reauthenticateWithCredential,EmailAuthProvider. If the app has a profile/settings page, these Firebase Auth functions are imported. PocketBase equivalents:updatePasswordcallspb.collection('users').update(uid, {password, passwordConfirm}).reauthenticateWithCredentialre-runsauthWithPassword.EmailAuthProvider.credential(email, pass)returns a credential object for re-auth. -
setDoc,limit,onSnapshotare commonly imported but easy to miss.setDoc= upsert (tryupdate, fallback tocreatewith explicit ID).limit(n)returns a constraint object used in query building.onSnapshotcan be a simple polling one-shot that callsgetDocs/getDocthen fires the callback — most apps only need the initial snapshot, not real-time. -
limitmust be handled ingetDocs. When a query has a limit constraint, usepb.collection(name).getList(1, limitVal, options)instead ofgetFullList().getFullListfetches all pages;getListrespects the pagination cap. -
PocketBase SDK auto-cancellation (
requestKey: null). The PocketBase JS SDK auto-cancels in-flight requests that share the samerequestKey(derived from method + URL by default). Allcreatecalls to the same collection share a key, so a double-submit cancels the first request with "auto-cancelled" error. Fix: pass{ requestKey: null }to ALL mutating calls —pb.collection(name).create(data, { requestKey: null }),pb.collection(name).update(id, data, { requestKey: null }), and the same insidesetDoc's upsert paths. Also add a client-side guard: disable the submit button on first click to prevent double submissions. -
index.htmlmay loadfirebase.jsvia<script>tag.<script type="module" src="firebase.js"></script>needs to becomesrc="pocketbase.js". HTML files are easy to miss when swapping imports in*.jsfiles only. -
Mount paths must match entrypoint flags (see
pocketbase-setupskill). The image uses--dir=/pb_data --publicDir=/pb_public. -
Docker restart ignores compose changes. After editing volumes/ports, use
down && up -d. -
Admin
upsertneeds--dir=/pb_dataflag when running inside container. -
Auth API uses
_superusers, notadminsin PocketBase v0.22+. -
Users collection system ID is
_pb_users_auth_— use PATCH on that ID, not POST to/api/collections/users. -
Rules referencing fields fail if created with the collection. Create with empty rules, then PATCH rules after schema exists.
-
Dynamic
await import()calls are easy to miss — check for both"..."and'...'quoting. -
login.htmlmay have inline<script type="module">with Firebase imports — check HTML files too, not just JS. -
sendPasswordResetEmailandupdateProfilearen't in the base adapter — add them if the login page uses them. -
Nested objects get JSON.stringified by the adapter — Firestore stores them natively. This is lossy but reversible for most data shapes.
-
Timestamp fields are auto-detected by field name (writeupTime, createdAt, etc.) — add to
TIMESTAMP_FIELDSSet if the app has differently-named timestamp fields. -
Admin auth leaks into app pages. If the user logged into PocketBase Admin UI (
/_/) on the same origin, the superuser auth token persists in localStorage. On next visit to the app,pb.authStore.isValidreturns true andcurrentUseris a superuser — the dashboard loads without login. Fix: in the adapter'sauth.currentUsergetter andonAuthStateChangedlistener, checkmodel.collectionName === 'users'. Return null for_superusers. This prevents admin sessions from auto-authenticating the app. -
Cached user tokens also bypass login. After fixing the admin auth leak (above), the user may still see the dashboard instead of the login page. This happens because the PocketBase SDK auto-restores any valid token from localStorage at initialization — including tokens from a previous
test@...user login. The adapter's admin filtering only rejects_superusers; a validuserscollection token passes right through. Fix: either (a) instruct the user to clear site data (DevTools → Application → Clear storage, or hard refresh withCtrl+Shift+Delete→ clear cache), or (b) add a manual sign-out button that callspb.authStore.clear(). Without one of these, the user is permanently auto-authenticated until the token expires. -
Dynamic imports in
shared/resolve to the wrong directory. After swapping Firebase CDN imports to./pocketbase.jsin Step 5, files insideshared/(likeshared/header-functionality.js) will have broken paths.import('./pocketbase.js')fromshared/resolves toshared/pocketbase.js— butpocketbase.jslives in the project root. Fix: change toimport('../pocketbase.js')in allshared/files. Also check for stale Firebase paths:import('../firebase.js')must becomeimport('../pocketbase.js'). Search with:grep -rn "pocketbase.js\|firebase.js" shared/. -
Nginx
Cache-Control: immutablebreaks all JS updates. Many static-site nginx configs setexpires 30d; add_header Cache-Control "public, immutable"on JS/CSS files. After swapping to PocketBase, browsers serve stale${OLD}JS files forever — even after the fix above. Change toexpires 1h; add_header Cache-Control "public, must-revalidate"and reload nginx. Also add?cb=cache-busting to all redirects as a defense-in-depth measure (seeweb-ui-repairskill, section 2). -
CSP on index.html silently blocks all inline fixes after migration. Post-migration inline script guards and onclick handlers may be blocked by a Content Security Policy meta tag that lacks
'unsafe-inline'. Diagnostic signal: inline fixes work on other pages (no CSP) but fail on index.html. Fix: add'unsafe-inline'to script-src. Full pattern inweb-ui-repairskill, section 13. -
Direct SQLite field manipulation breaks PocketBase schema validation. If you modify the
_collectionstable'sfieldscolumn directly (instead of via the API), each field object in the JSON array MUST include a uniqueidproperty (a string like"text3208210256"that PocketBase generated at creation time). Without valididproperties, PocketBase cannot parse the field definitions and ALL create/update operations fail with a generic"Failed to create record."error — regardless of user permissions or data validity.\n\n Fix: Always usePATCH /api/collections/{id}via the API to add fields. This generates properidvalues automatically. Fetch the existing collection first (GET /api/collections/{id}), merge new fields into the returnedfieldsarray, then PATCH back.\n\n- Define ALL fields before CRUD, not just userId.** Unlike Firestore (schemaless), PocketBase has a strict typed schema. Creating/updating a record with a field not defined in the collection schema returns HTTP 200 but the field value is discarded — no error, no warning, just lost data. This is the #1 cause of "everything looks like it works but there's no data." Define every field the app writes before any CRUD. See Step 3 for the two-step schema+rules process and thereferences/collection-schema-setup.pytemplate. -
Import consolidation can accidentally drop non-Firebase imports. When manually consolidating Firebase CDN imports into
./pocketbase.js, adjacent non-Firebase imports (likeimport { escapeHtml } from './shared/sanitize.js') may be deleted. After consolidation, grep each file for undeclared function references:grep -n "escapeHtml\|showNotification\|formatCurrency" *.js shared/*.js | grep -v "^.*:import". If a function is used but not imported, restore the import. -
Restrictive file permissions break module loading. When copying migrated files to the deployment directory, files may have owner-only permissions (600). nginx runs as
www-dataand returns HTTP 403 for unreadable files — which causes silent ES module chain failure (no console error visible but no handlers attach). Fix:find . -name "*.js" -exec chmod 644 {} +after deployment. Verify withcurl -skI https://site.com/key-file.js | head -1— should return 200, not 403. -
Settings save/load breaks silently on PocketBase schema mismatch. If a collection (e.g.,
settings) only hasid,userId,nametext fields but the app tries to save arbitrary key-value settings directly, PocketBase silently drops unknown fields. Fix: add ajsonfield to the collection, wrap data in{data: {...}, userId, name}, and use query-based upsert insetDoc/getDoc(PocketBase v0.23+ requires system IDs ≥ 15 chars). Full pattern inreferences/settings-json-field-pattern.md.
Debugging
See references/debugging-migration-bugs.md for common bug patterns discovered during migration validation, including:
| Pattern | Symptom | Fix |
|---|---|---|
| Dual render path filter mismatch | Completed items stay visible after status change | Sync filter between load-from-DB and re-render-local paths |
| Silent setTimeout errors | Button click "does nothing", no console error | Try/catch inside the callback, not outside |
| PocketBase drops undefined fields | API returns 200 but data is missing | Define every field in schema before CRUD |
| Reference build comparison | "But the Firebase version works!" — same code bug | Use diff on identical functions; the Firebase version had the same bug, just unnoticed |