Seed Protocol
Recipes

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 / SupabaseSeed ProtocolNotes
Project, databaseA schema file, plus a local database on each deviceThe schema is versioned JSON in your repo (Your first schema)
Collection / tableModelDefined in the schema, not created at runtime
Document / rowItemIdentified by a local seedLocalId, and by a public seedUid once published
Field / columnProperty, with one of ten typesText holds 255 characters unless you set a limit; use Html for long text
Document reference / foreign keyRelation (one) or List of Relation (many)Holds the other item's ID string. No joins, no cascading deletes
SubcollectionA separate model with a Relation to its parentPut the relation on the "many" side (Model relations between items)
Cloud Storage / Supabase StorageImage and File propertiesStored on the device; uploaded to Arweave, publicly and permanently, when the item is published
Auth user, uidAn account addressWith 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 securityLocal ownership checks, and the chainThe 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 channelsuseItems, useItem, useLiveQueryThey follow the local database, in every tab. Other devices' and other users' changes arrive only through a sync of published data
Offline persistenceThe defaultEverything is written locally first (Local-first and offline)
where, orderBy, limitFilter in JavaScript, or SQL with useLiveQuerySQL runs against the SDK's internal tables (Querying with liveQuery)
Queries over every user's data@seedprotocol/query and @seedprotocol/eas, over published dataReads from the public EAS indexer. No aggregation, no full-text search
Cloud Functions, Edge FunctionsYour own server running the SDKSee Use Seed in Node and Electron. There are no triggers on other users' writes
Writes from a server on a user's behalfSession keysThe user grants a key that can publish as them, but not revoke (Publish unattended)
Transactions, batched writesNone for items; BaseDb.transaction for your own tablesEach item property saves on its own. Raw writes to tables your app adds can share one transaction (BaseDb.transaction)
DeleteSoft delete locally; unpublish to revoke published dataRevoked data stays readable (Permanence)
MigrationsRaise the schema file's version with every changeSee 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 typeProperty typeHow to convert
Short string, varcharTextUp to 255 characters by default
Long string, text, rich textHtmlNo length limit
Number, integer, numericNumberReads back as a numeric string; see What you get back. Publishes as its decimal string, so large, negative and decimal values publish
BooleanBooleanReads back as 'true' or 'false'
Timestamp, timestamptzDateAn ISO 8601 string. A number, such as milliseconds since the epoch, is rejected
Map, jsonbJsonJSON.stringify it yourself
Array of strings, text[]List with refValueType: "Text"
Reference, foreign keyRelation with model
Array of references, join tableList with refValueType: "Relation" and ref
Storage file URLImage or FileFetch 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).

seed.schema.json
{
  "$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 / SupabaseSeed (React)
onSnapshot(collection(db, 'posts'), …), supabase.from('posts').select() + a channeluseItems({ 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 scriptItem.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.

src/AuthorPosts.tsx
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:

export.json
{
  "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.

src/importData.ts
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

scripts/import.ts
// 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.json
2 authors, 2 new posts

Running 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:

src/ImportButton.tsx
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 featureIn Seed Protocol
Access control on readsNone. Security rules and row-level security have no counterpart: anything published is readable by anyone, through any EAS indexer and Arweave gateway
Private dataOnly unpublished drafts are private, and they live unencrypted in the browser's storage on one device. There's no encryption API
Restricting who writesThe 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 dataOnly 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 writesNone 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 triggersNo 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 usersNo push. Each device pulls published data when it syncs (Sync a user's data to another device)
Hard delete, GDPR erasureNot for published data. Revoking hides it from Seed's readers; the attestations and Arweave files remain
Admin access to user dataNone. Your app can't edit, unpublish or recover a user's published items, and can't reset their account
MainnetSeed 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 offline event, Item.create never resolves for a model whose EAS schema UID isn't stored locally, so importData waits 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.

On this page