---
name: firebase-to-pocketbase-migration
description: Migrate a Firebase web app to self-hosted PocketBase — build a Firebase-compatible adapter, swap imports, and deploy with same-origin nginx proxy.
triggers:
- "migrate from Firebase"
- "replace Firebase with PocketBase"
- "Firebase to PocketBase"
- "remove Firebase dependency"
- "self-host instead of Firebase"
---
# 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:
```bash
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)
```python
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)
```python
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
```python
# 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 unsubscribe
- `signInWithEmailAndPassword(auth, email, password)` → PocketBase `authWithPassword()`
- `createUserWithEmailAndPassword(auth, email, password)` → PocketBase `create()` + `authWithPassword()`
- `signOut(auth)` → `pb.authStore.clear()`
- `sendPasswordResetEmail(auth, email)` → PocketBase `requestPasswordReset()`
- `updateProfile(user, {displayName})` → PocketBase `update()`
### Firestore surface
- `collection(db, name)` / `collection(db, 'users', uid, 'sub')` — top-level and sub-collection
- `doc(db, 'col', id)` / `doc(db, 'users', uid, 'settings', name)` — top-level and nested
- `query(ref, where(field, op, val), orderBy(field, dir))` — compiled to PocketBase filter strings
- `getDocs(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 return `exists` as a function** (`exists: () => true`/`exists: () => false`), not a boolean. Firestore API calls `.exists()` everywhere.
- `addDoc(ref, data)` → `pb.collection(name).create(data)`. **⚠️ Must inject `userId` from sub-collection ref.** When app code writes `collection(db, 'users', uid, 'services')`, the adapter tracks `_userId` on the ref but `create()` discards it. Always: `if (ref._userId && !data.userId) { data.userId = ref._userId; }` before calling `create()`.
- `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 bare `getDocs(query).then(docs => callback({docs}))` is wrong. Wrap in `createQuerySnapshot(docs)` that includes `size`, `empty`, `forEach`. Reference the same helper used by `getDocs`.
- `serverTimestamp()` → `new Date().toISOString()`
- `Timestamp` class — 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. `undefined` values dropped. Dates → ISO strings.
- **From PB**: ISO strings on known timestamp fields → `Timestamp` objects. JSON strings in object-like fields → parsed back.
## Step 5: Swap All Imports
Replace Firebase CDN imports with the adapter in one sweep:
```bash
# 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:
```bash
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 files
- `location /api/ { proxy_pass http://127.0.0.1:8091; }` proxies PocketBase
- `location /_/ { 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
- **`onAuthStateChanged` must be exported as a standalone function.** Many files import `import { onAuthStateChanged } from "./pocketbase.js"` and call `onAuthStateChanged(auth, callback)`. If you only export it as `auth.onAuthStateChanged` (a method on the auth object), those imports resolve to `undefined` and 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: `updatePassword` calls `pb.collection('users').update(uid, {password, passwordConfirm})`. `reauthenticateWithCredential` re-runs `authWithPassword`. `EmailAuthProvider.credential(email, pass)` returns a credential object for re-auth.
- **`setDoc`, `limit`, `onSnapshot` are commonly imported but easy to miss.** `setDoc` = upsert (try `update`, fallback to `create` with explicit ID). `limit(n)` returns a constraint object used in query building. `onSnapshot` can be a simple polling one-shot that calls `getDocs`/`getDoc` then fires the callback — most apps only need the initial snapshot, not real-time.
- **`limit` must be handled in `getDocs`.** When a query has a limit constraint, use `pb.collection(name).getList(1, limitVal, options)` instead of `getFullList()`. `getFullList` fetches all pages; `getList` respects the pagination cap.
- **PocketBase SDK auto-cancellation (`requestKey: null`).** The PocketBase JS SDK auto-cancels in-flight requests that share the same `requestKey` (derived from method + URL by default). All `create` calls 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 inside `setDoc`'s upsert paths. Also add a client-side guard: disable the submit button on first click to prevent double submissions.
- **`index.html` may load `firebase.js` via `` needs to become `src="pocketbase.js"`. HTML files are easy to miss when swapping imports in `*.js` files only.
- **Mount paths must match entrypoint flags** (see `pocketbase-setup` skill). The image uses `--dir=/pb_data --publicDir=/pb_public`.
- **Docker restart ignores compose changes.** After editing volumes/ports, use `down && up -d`.
- **Admin `upsert` needs `--dir=/pb_data`** flag when running inside container.
- **Auth API uses `_superusers`, not `admins`** in 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.html` may have inline `