Skip to main content
This guide covers Firestore (collections and documents). Firebase’s older Realtime Database is a different model, a single JSON tree, and needs its own mapping. If your app is on the Realtime Database, most of the auth and functions sections still apply, but the data model does not.

Why Pylon fits

Firebase and Pylon share the shape of a backend: a data store, auth, server functions, file storage, and access rules. Most of your app maps directly:
  • documents with fields and references map to entities with typed fields and foreign keys;
  • security rules map to per-entity policies, and both deny by default;
  • onSnapshot live listeners map to reactive queries against a local replica;
  • Firebase Auth maps to Pylon auth (email/password, email link, OAuth, anonymous).
The one real shift: Firestore is schemaless; Pylon is schema-first. In Firestore a document is any shape. In Pylon you declare each entity’s fields and types once, and get types everywhere. The main migration work is writing that schema for data that never had one. What Pylon adds:
  • Hosted server functions in TypeScript (query / mutation / action), each in a transaction, instead of deploying Cloud Functions.
  • Server-side rendering: file-based routes, one binary serving frontend and API.
  • Relational queries with include, plus aggregates and vector search.
  • A single Rust binary you can self-host, or one-command Pylon Cloud.

Concept map

Migrating your schema

Firestore has no schema, so first read your data and write one. For each collection, declare a Pylon entity with typed fields. Turn a reference (or an id string) into a field.id("Target") foreign key. Firestore — a posts collection, documents shaped like:
Pylon (app.ts):
Notes:
  • Map Firestore field types to Pylon: string, number, boolean, and Timestamp map to field.string/int/float/bool/datetime. A Map (nested object) or Array maps to field.json(). Geopoint and Bytes have no direct type: store a Geopoint as field.json() ({ lat, lng }) and Bytes as a base64 field.string().
  • A subcollection (posts/{id}/comments) has no direct equivalent. Flatten it to a top-level Comment entity with a postId: field.id("Post") field.
  • Firestore’s composite-index requirements become indexes on the entity.

Migrating your security rules

Both systems deny by default, so this is a translation, not a change of posture. Write a policy() for every entity, because an entity with no policy is blocked. Firestore (firestore.rules):
Pylon (policy(...) in app.ts):
How to convert each rule:
  • request.auth.uid maps to auth.userId. request.auth != null maps to auth.userId != null.
  • resource.data.* (the current row) maps to data.* on reads, and to existing.* on update and delete.
  • request.resource.data.* (the incoming write) maps to data.* in the insert and update policy.
  • get() / exists() cross-document checks map to exists(Entity where field == auth.userId).
  • To lock an immutable field (Firestore’s request.resource.data.author == resource.data.author), declare the column field.string().owner(). It stamps auth.userId on insert, rejects a false owner, and locks the value on update.

Migrating reads and writes

Reads. A Firestore query becomes db.useQuery, which is reactive by default (no separate onSnapshot).
Writes. setDoc/addDoc/updateDoc/deleteDoc become the client entity hook or a server function.
A writeBatch or runTransaction becomes one mutation handler (the whole handler is a transaction) or a POST /api/batch call.

Migrating your data

Use the Admin SDK to read your data out. The managed gcloud firestore export writes a LevelDB format meant for restore into Firestore or BigQuery, not for loading into another database, so it is the wrong tool here. 1. Export with the Admin SDK. Firestore has no single “dump everything” call, so enumerate collections and recurse into subcollections.
2. Convert ids, references, and timestamps. Firestore ids are 20-character base62 strings. Pylon ids are 40-character lowercase hex, so you cannot reuse Firestore ids directly (Pylon rejects a bad id with INVALID_ID). Use one of two methods:
  • Derive the id (simplest). Convert each Firestore id to a Pylon id with a fixed function, and apply the same function to every reference. sha1(firestoreId) gives 40 lowercase hex characters, so it fits Pylon’s format and needs no lookup table. Derived ids are not time-ordered, so sort by your createdAt fields.
  • Map the id. Generate a new Pylon id per row with generateId() from @pylonsync/sync, keep an old-to-new map, and rewrite references in a second pass.
Also convert a Firestore Timestamp to an ISO string, and a reference to its target id in a field.id column. 3. Import into Pylon. The admin batch endpoint loads rows quickly and bypasses per-entity policies. It needs an admin token. Send batches of a few hundred rows.
4. Files. Download each object from Firebase Storage and upload it to Pylon’s file storage, then rewrite the stored paths and URLs. 5. Users. Export your Firebase Auth users (firebase auth:export users.json) and import each into your Pylon User entity, keyed by email. Sessions do not transfer, so users sign in again. Password hashes do not transfer either; move users to Pylon’s magic-code or OAuth sign-in, where email is the stable key.

Migrating auth

Firebase’s providers each have a Pylon equivalent.
  • Email link (sendSignInLinkToEmail / signInWithEmailLink) maps to Pylon’s magic code: POST /api/auth/magic/send then /magic/verify.
  • OAuth (signInWithPopup(new GoogleAuthProvider())) maps to a redirect to GET /api/auth/login/:provider?callback=<url>. Pylon supports Google, GitHub, Apple, Microsoft, and about 20 more providers.
  • Anonymous (signInAnonymously) maps to a Pylon guest session (POST /api/auth/guest).
  • onAuthStateChangeduser.uid maps to db.useUser() on the client and ctx.auth.userId on the server.

What does not map cleanly

Decide on these before you commit:
  • Schemaless documents. Firestore lets each document be any shape. Pylon is schema-first, so you define the fields. Keep genuinely freeform data in a field.json() column, but an update replaces the whole field (there is no per-key merge like updateDoc’s dotted paths).
  • Subcollections. Flatten each subcollection to a top-level entity with a parent field.id column. There is no nested-collection path in Pylon.
  • Document references. A Firestore Reference field becomes a plain id string in a field.id column. You resolve it with include, not a second getDoc.
  • Write triggers. Firestore’s onDocumentWritten reacts to any write on a path. Pylon has no per-write trigger. Do the derived work inside the mutation that makes the write, or use a reactive query or a scheduled job.
  • Field types with no Pylon equivalent: Geopoint (field.json() as {lat,lng}) and Bytes (base64 in a field.string()).
  • Realtime Database. Its JSON-tree model is out of scope for this guide.
None of these blocks a typical app. Each one needs a decision, not a mechanical rewrite.

Get started

  1. Create a new Pylon app: npm create @pylonsync/pylon@latest my-app.
  2. Write a schema (entity and field) for each Firestore collection, and a policy() for every entity.
  3. Run the Admin SDK export, the conversion, and the /api/batch import against a staging app first.
  4. Point the client at Pylon: init, the auth calls, db.useQuery, and db.useEntity.
  5. Run pylon deploy, or self-host the single binary.