Migrating from Firebase or Supabase
Map Firestore and Supabase concepts onto Seed Protocol, port a small app's reads and writes, import existing data with a script, and know which backend features have no equivalent.
Firebase and Supabase keep your data on a server you control, behind rules you write. Seed Protocol keeps each user's data on their device, and publishes it, signed by their own account, to a public ledger that any app can read. A lot of app code ports almost line for line. The architecture underneath doesn't, and some backend features have no equivalent at all. Read What has no equivalent before you commit to a migration.
You'll need: a Vite + React app set up as in Add Seed to your app. Publishing, the last step, also needs the setup from Publishing your first item.
Concept map
| Firebase / Supabase | Seed Protocol | Notes |
|---|---|---|
| Project, database | A schema file, plus a local database on each device | The schema is versioned JSON in your repo (Your first schema) |
| Collection / table | Model | Defined in the schema, not created at runtime |
| Document / row | Item | Identified by a local seedLocalId, and by a public seedUid once published |
| Field / column | Property, with one of ten types | Text holds 255 characters unless you set a limit; use Html for long text |
| Document reference / foreign key | Relation (one) or List of Relation (many) | Holds the other item's ID string. No joins, no cascading deletes |
| Subcollection | A separate model with a Relation to its parent | Put the relation on the "many" side (Model relations between items) |
| Cloud Storage / Supabase Storage | Image and File properties | Stored on the device; uploaded to Arweave, publicly and permanently, when the item is published |
Auth user, uid | An account address | With ConnectButton, users sign in with email, phone, a passkey or Farcaster, and get a smart account (the ManagedAccount) that authors what they publish (Identity and signing) |
| Security rules / row-level security | Local ownership checks, and the chain | The SDK only lets the user edit items their account owns; on-chain, only the author can revoke. There's no way to restrict who reads published data |
onSnapshot, realtime channels | useItems, useItem, useLiveQuery | They follow the local database, in every tab. Other devices' and other users' changes arrive only through a sync of published data |
| Offline persistence | The default | Everything is written locally first (Local-first and offline) |
where, orderBy, limit | Filter in JavaScript, or SQL with useLiveQuery | SQL runs against the SDK's internal tables (Querying with liveQuery) |
| Queries over every user's data | @seedprotocol/query and @seedprotocol/eas, over published data | Reads from the public EAS indexer. No aggregation, no full-text search |
| Cloud Functions, Edge Functions | Your own server running the SDK | See Use Seed in Node and Electron. There are no triggers on other users' writes |
| Writes from a server on a user's behalf | Session keys | The user grants a key that can publish as them, but not revoke (Publish unattended) |
| Transactions, batched writes | None for items; BaseDb.transaction for your own tables | Each item property saves on its own. Raw writes to tables your app adds can share one transaction (BaseDb.transaction) |
| Delete | Soft delete locally; unpublish to revoke published data | Revoked data stays readable (Permanence) |
| Migrations | Raise the schema file's version with every change | See Define a schema and add to it |
The biggest shift: in Firebase or Supabase a user writes to your database. In Seed a user writes to their own device, and publishing makes it theirs, publicly. Your app no longer holds the data, so it can't be the only one that reads it, and it can't take it back.
1. Port the data model
Each Firestore field or Postgres column becomes a property:
| Source type | Property type | How to convert |
|---|---|---|
Short string, varchar | Text | Up to 255 characters by default |
Long string, text, rich text | Html | No length limit |
Number, integer, numeric | Number | Reads back as a numeric string; see What you get back. Publishes as its decimal string, so large, negative and decimal values publish |
| Boolean | Boolean | Reads back as 'true' or 'false' |
Timestamp, timestamptz | Date | An ISO 8601 string. A number, such as milliseconds since the epoch, is rejected |
Map, jsonb | Json | JSON.stringify it yourself |
Array of strings, text[] | List with refValueType: "Text" | |
| Reference, foreign key | Relation with model | |
| Array of references, join table | List with refValueType: "Relation" and ref | |
| Storage file URL | Image or File | Fetch the file and assign it; see File and image properties |
For the example, a posts collection with an authorId reference and an authors collection. The schema replaces your app's blog schema, so set version above the one your app last ran. An edit at the same version replaces that version's stored copy of the schema, so the previous one is gone (Evolving the model).
{
"$schema": "https://seedprotocol.io/schemas/data-model/v1",
"version": 3,
"metadata": {
"name": "blog",
"createdAt": "2026-10-15T00:00:00.000Z",
"updatedAt": "2026-10-15T00:00:00.000Z"
},
"models": {
"Post": {
"properties": {
"title": { "type": "Text", "required": true },
"body": { "type": "Html" },
"published": { "type": "Boolean" },
"publishedAt": { "type": "Date" },
"tags": { "type": "List", "refValueType": "Text" },
"metadata": { "type": "Json" },
"author": { "type": "Relation", "model": "Author", "required": true },
"importedFrom": { "type": "Text" }
}
},
"Author": {
"properties": {
"name": { "type": "Text", "required": true },
"bio": { "type": "Text" },
"importedFrom": { "type": "Text" }
}
}
},
"enums": {},
"migrations": []
}importedFrom keeps the old document ID, so an import can run twice without duplicating anything, and so you can trace an item back to its source. published is the old app's own flag, kept as data. Publishing on Seed is a different thing, covered in step 4.
2. Port the reads and writes
| Firebase / Supabase | Seed (React) |
|---|---|
onSnapshot(collection(db, 'posts'), …), supabase.from('posts').select() + a channel | useItems({ modelName: 'Post' }) for the IDs, useItem per row (why) |
onSnapshot(doc(db, 'posts', id), …) | useItem({ modelName: 'Post', seedLocalId }) |
where('authorId', '==', id) | A live query, as below, or filter useItems results |
addDoc(…), .insert(…) | createItem('Post', { title, author }) from useCreateItem |
updateDoc(…, { published: true }), .update(…) | post.published = true. For text inputs, hold unsaved text in the input, as in Editing items optimistically |
deleteDoc(…), .delete() | deleteItem(post) from useDeleteItem |
getDocs(…) in a script | Item.all('Post', false, { waitForReady: true }) |
Here's a component that lists one author's posts, newest first, with the Firestore calls it replaces. The query is a live query, so the list follows creates, deletes and changed authors, in every tab. A filter over useItems results wouldn't: useItems only re-renders when items are created or deleted, and its rows can be read before their values have loaded.
import { useMemo } from 'react'
import type { Item } from '@seedprotocol/sdk'
import { useCreateItem, useDeleteItem, useItem, useLiveQuery } from '@seedprotocol/react'
import type { Post } from './types'
import { asBoolean, asIdList } from './values'
// Was: onSnapshot(query(collection(db, 'posts'), where('authorId', '==', authorId),
// orderBy('createdAt', 'desc')), …)
export function AuthorPosts({ authorId }: { authorId: string }) {
const query = useMemo(
() => (sql: any) => sql`
SELECT s.local_id FROM seeds s
WHERE s.type = 'post'
AND (s._marked_for_deletion IS NULL OR s._marked_for_deletion = 0)
AND (s.revoked_at IS NULL OR s.revoked_at = 0)
AND (SELECT m.property_value FROM metadata m
WHERE m.seed_local_id = s.local_id AND m.property_name IN ('author', 'authorId')
ORDER BY (m.revoked_at IS NOT NULL),
COALESCE(m.attestation_created_at, m.created_at) DESC, m.local_id DESC
LIMIT 1) = ${authorId}
ORDER BY s.created_at DESC`,
[authorId],
)
const rows = useLiveQuery<{ local_id: string }>(query)
const { createItem, isLoading: creating } = useCreateItem()
if (rows === undefined) return <p>Loading…</p>
return (
<section>
{/* Was: addDoc(collection(db, 'posts'), { title: 'Untitled', authorId }) */}
<button disabled={creating} onClick={() => createItem('Post', { title: 'Untitled', author: authorId })}>
New post
</button>
<ul>
{rows.map((row) => (
<PostRow key={row.local_id} seedLocalId={row.local_id} />
))}
</ul>
</section>
)
}
function PostRow({ seedLocalId }: { seedLocalId: string }) {
const { item } = useItem({ modelName: 'Post', seedLocalId })
const post = item as Post | undefined
const { deleteItem } = useDeleteItem()
if (!post) return null
return (
<li>
{post.title || 'Untitled'} {asIdList(post.tags).map((tag) => `#${tag}`).join(' ')}
<label>
{/* Was: updateDoc(doc(db, 'posts', id), { published: e.target.checked }) */}
<input
type="checkbox"
checked={asBoolean(post.published)}
onChange={(e) => {
post.published = e.target.checked
}}
/>
Published
</label>
{/* Was: deleteDoc(doc(db, 'posts', id)) */}
<button onClick={() => deleteItem(post as Item<any>).catch(() => {})}>
Delete
</button>
</li>
)
}types.ts and values.ts are the ones from Reading and writing data. Read tags through asIdList: a list can be undefined until the item has loaded.
The SQL reads the SDK's internal tables, which aren't a public API. New rows store a single Relation as authorId, and rows written by an older SDK can hold author, so the query matches both. Keep raw queries in one module. Querying with liveQuery explains the tables.
3. Import the existing data
Export
Export each collection or table to JSON, keeping document IDs and turning timestamps into ISO strings. For example, with the Firebase Admin SDK:
const snap = await firestore.collection('posts').get()
const posts = snap.docs.map((d) => ({
id: d.id,
...d.data(),
createdAt: d.get('createdAt')?.toDate().toISOString() ?? null,
}))or with supabase-js:
const { data: posts } = await supabase.from('posts').select('id, title, body, author_id, published, created_at, tags')The import below expects this shape:
{
"authors": [
{ "id": "u_ada", "name": "Ada Lovelace", "bio": "Notes on the Analytical Engine" },
{ "id": "u_grace", "name": "Grace Hopper", "bio": null }
],
"posts": [
{
"id": "p_1",
"title": "Hello from Firestore",
"body": "<p>Moved over with the import script.</p>",
"authorId": "u_ada",
"published": true,
"createdAt": "2024-03-01T09:30:00.000Z",
"tags": ["intro", "migration"],
"metadata": { "readingTime": 3 }
},
{
"id": "p_2",
"title": "A second post",
"body": "<p>Second.</p>",
"authorId": "u_grace",
"published": false,
"createdAt": "2024-04-12T15:00:00.000Z",
"tags": [],
"metadata": null
}
]
}The import function
importData only uses the SDK's Item API, so the same code runs in Node and in the browser. Authors are created first, so posts can point at them by seedLocalId, and every item records its source ID.
import { Item } from '@seedprotocol/sdk'
// The shape of the export. Adapt it to what your Firestore or Supabase export produces.
export type ExportFile = {
authors: { id: string; name: string; bio?: string | null }[]
posts: {
id: string
title: string
body?: string | null
authorId: string
published?: boolean
createdAt?: string | null
tags?: string[]
metadata?: unknown
}[]
}
// Text holds 255 characters unless the schema says otherwise. create() rejects a longer value too;
// this error names the field.
function text(value: unknown, field: string): string | undefined {
if (value == null) return undefined
const s = String(value)
if (s.length > 255) throw new Error(`${field} is ${s.length} characters; use an Html or File property`)
return s
}
// Items imported by an earlier run, keyed by their source ID, so a re-run skips them.
async function imported(modelName: string): Promise<Map<string, Item<any>>> {
const items = await Item.all(modelName, false, { waitForReady: true })
const byId = new Map<string, Item<any>>()
for (const item of items) {
const source = (item as Item<any> & { importedFrom?: string }).importedFrom
if (source) byId.set(source, item)
}
return byId
}
export async function importData(data: ExportFile) {
const authorIds = new Map<string, string>() // source ID -> seedLocalId
const existingAuthors = await imported('Author')
for (const row of data.authors) {
let author = existingAuthors.get(row.id)
if (!author) {
const values = {
modelName: 'Author',
name: text(row.name, 'name'),
bio: text(row.bio, 'bio'),
importedFrom: row.id,
}
author = await Item.create(values)
}
authorIds.set(row.id, author.seedLocalId)
}
const existingPosts = await imported('Post')
let created = 0
for (const row of data.posts) {
if (existingPosts.has(row.id)) continue
const author = authorIds.get(row.authorId)
if (!author) throw new Error(`Post ${row.id}: unknown author ${row.authorId}`)
const values = {
modelName: 'Post',
title: text(row.title, 'title'),
body: row.body ?? undefined, // Html has no length limit
published: Boolean(row.published),
publishedAt: row.createdAt ? new Date(row.createdAt).toISOString() : undefined,
tags: row.tags?.map((tag) => text(tag, 'tag')),
metadata: row.metadata == null ? undefined : JSON.stringify(row.metadata),
author, // a Relation takes the related item's seedLocalId
importedFrom: row.id,
}
await Item.create(values)
created++
}
return { authors: authorIds.size, newPosts: created }
}Item.create rejects a value over the Text limit with ItemCreateValidationError and writes nothing, so a failed import leaves no half-written item behind. Dates and Json are converted before create, for the reasons in the table above.
Run it in Node
// Run on Node 22.18 or later (it runs .ts directly):
// node scripts/import.ts export.json
import { readFile } from 'node:fs/promises'
import { client } from '@seedprotocol/sdk'
import { importData, type ExportFile } from '../src/importData.ts'
const schema = JSON.parse(await readFile('seed.schema.json', 'utf8'))
const data: ExportFile = JSON.parse(await readFile(process.argv[2] ?? 'export.json', 'utf8'))
await client.init({ config: { filesDir: process.env.SEED_DATA_DIR ?? 'blog-data', schema } })
const result = await importData(data)
console.log(`${result.authors} authors, ${result.newPosts} new posts`)
await client.stop()npm install @seedprotocol/sdk
node scripts/import.ts export.json2 authors, 2 new postsRunning it again prints 2 authors, 0 new posts. Node runs the .ts files directly from version 22.18; on older versions, compile them first. The process exits once client.stop() resolves. A relative filesDir such as 'blog-data' puts the database under .seed/ in the working directory, so run the script where you want the data, or set SEED_DATA_DIR to an absolute path (Choose where the data lives).
The items land in the Node process's own database (.seed/blog-data/db/seed.db by default), not in any user's browser. That's what you want for a server-side Seed app, or to check the mapping. It isn't how a user's data reaches their app: see the next section.
Or run it in the user's browser
Each user's content should end up on their device, owned by their account, so that they can publish it as theirs. The simplest route is to let each user import their own export in the app, with the same importData:
import { useState } from 'react'
import { importData, type ExportFile } from './importData'
export function ImportButton() {
const [result, setResult] = useState<string | null>(null)
async function handleFile(file: File) {
setResult('Importing…')
try {
const data = JSON.parse(await file.text()) as ExportFile
const { authors, newPosts } = await importData(data)
setResult(`Imported ${authors} authors and ${newPosts} new posts.`)
} catch (err) {
setResult(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
return (
<div>
<label>
Import an export file
<input
type="file"
accept="application/json"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void handleFile(file)
}}
/>
</label>
{result && <p role="status">{result}</p>}
</div>
)
}Serve each signed-in user their own export from your old backend while it's still running, or let them upload a file. Items imported before the user connects a wallet are claimed by their account when they connect (Ownership and drafts).
4. Publish
Imported items are local drafts. Nothing leaves the device until the user publishes, which is when their account becomes the author. Publish each post with post.publish(), set up as in Publishing your first item; its author is published with it. Publishing and revoking covers retries and costs.
Before publishing imported data, decide whether it should be public at all: everything published is public and permanent. Unpublishing marks it revoked, but doesn't remove it.
There's no way to publish everyone's existing data as them in one batch. Only a user's own account can author their items. The options are:
- Each user imports and publishes their own data (above).
- Each user grants your server a session key, and the server publishes for them. They still have to opt in one by one.
- You publish everything from one app account. Your app is then the author of every item, and users can't edit or revoke them.
What has no equivalent
| Backend feature | In Seed Protocol |
|---|---|
| Access control on reads | None. Security rules and row-level security have no counterpart: anything published is readable by anyone, through any EAS indexer and Arweave gateway |
| Private data | Only unpublished drafts are private, and they live unencrypted in the browser's storage on one device. There's no encryption API |
| Restricting who writes | The SDK only lets a user edit what their account owns, but anyone can publish items of any model under their own account. Readers decide whose items to trust, usually by filtering on the author (attester) |
| Server-side queries over everyone's data | Only over published data, through @seedprotocol/query or @seedprotocol/eas and the EAS indexer: list a model's items, page through them, or look up one account's items. No server-side filters on values, joins, counts or full-text search. A model's schema name is shared by every app that uses it, so results include other apps' items |
| Transactions and batched writes | None for items: each property saves independently, and a publish of several related drafts is several transactions, not one atomic write. Write items through Item, not raw SQL on the SDK's tables. Raw writes to your own tables can share one BaseDb.transaction |
| Server functions and triggers | No hosted runtime. Run the SDK in your own Node process (Use Seed in Node and Electron); it sees other accounts' data only after they publish and your process syncs |
| Realtime across users | No push. Each device pulls published data when it syncs (Sync a user's data to another device) |
| Hard delete, GDPR erasure | Not for published data. Revoking hides it from Seed's readers; the attestations and Arweave files remain |
| Admin access to user data | None. Your app can't edit, unpublish or recover a user's published items, and can't reset their account |
| Mainnet | Seed Protocol runs on the OP Sepolia testnet only. Everything published today is test data |
If your app depends on any of these for the data you're moving, keep that data where it is, and move only what's meant to be public and owned by its author.
Gotchas
- A browser import can hang offline. After the browser's
offlineevent,Item.createnever resolves for a model whose EAS schema UID isn't stored locally, soimportDatawaits on its first item and stays pending after the connection comes back. Starting the app online first doesn't prevent it. Run browser imports while online (Keep writing while offline). - IDs change. Old document IDs aren't Seed IDs. Keep them in a property like
importedFrom, and rewrite links in your content (for example/posts/<old-id>) to the new IDs or slugs.
Related
- Tutorial: Your first schema, Reading and writing data, Publishing your first item
- Guides: Model relations between items, Querying with liveQuery, Use Seed in Node and Electron, Import an RSS feed into your models (for content that's already in RSS or Markdown), Read published Seed data from another app or a server
- Concepts: Local-first and offline, Ownership and drafts, Identity and signing, Permanence
- Recipes: Build a publishing app