A document you edit across tabs and devices
A document one person edits in several tabs and on several devices: live updates between tabs, a warning when the text changed elsewhere, a keep-or-take choice when a sync conflicts with an unpublished edit, publishing to move it between devices, and what Seed Protocol can't do for real-time collaboration.
A small document editor for one owner who works in several browser tabs and on more than one device. Edits show up in the other tabs as soon as they're saved, the editor warns when the text changed in another tab or was published from another device, it lets the user keep their edit or take the synced value when the two conflict, and publishing is how a version gets from one device to the next.
This isn't Google Docs. Seed Protocol has no real-time co-editing between people: no shared editing session, no presence or cursors, no merging of concurrent edits, and no conflict check between tabs. A document belongs to the account that publishes it, and only that account can change it. What isn't possible lists the gaps, and what you can do instead.
What you'll build
Between tabs on one device there's nothing to set up: all tabs share one local database, so a saved edit shows in every tab. Between devices only published versions move. The user publishes a version on one device, and the other device picks it up on its next sync.
You'll need: a Vite + React app set up as in Add Seed to your app, with publishing configured as in Publishing your first item.
The schema
This schema replaces your app's seed.schema.json:
{
"$schema": "https://seedprotocol.io/schemas/data-model/v1",
"version": 1,
"metadata": {
"name": "notes",
"createdAt": "2026-10-15T00:00:00.000Z",
"updatedAt": "2026-10-15T00:00:00.000Z"
},
"models": {
"Document": {
"properties": {
"title": { "type": "Text", "required": true },
"body": { "type": "Html" }
}
}
},
"enums": {},
"migrations": []
}The body is Html, which has no length limit. The editor treats it as text the user types; for a rich-text editor, see What's left to you.
1. Copy the helpers
Copy these files into src/ unchanged:
| File | From | What it does |
|---|---|---|
seed.ts | Add Seed to your app | The client config |
publish.ts | Publishing your first item | The publish config, registered with initPublish |
useDraftProperty.ts | Editing items optimistically | Holds unsaved text while the user types, with changedElsewhere |
useIsOwned.ts | Editing items optimistically | Whether the user may edit the document |
syncTriggers.ts (keepInSync) | Sync a user's data to another device | Syncs when the browser comes back online and when the user returns to the tab, in the leader tab only |
SyncStatus.tsx | Sync a user's data to another device | "Last synced" and a Sync now button |
syncConflicts.ts (syncAndListConflicts, settleConflict) | Sync a user's data to another device | Syncs, then lists the conflicts between synced values and the user's unpublished edits; settles one |
publishGuard.ts (publishedElsewhere) | Conflict detection | Syncs, then says whether the document was published elsewhere since a given time |
TextField.tsx | Build a publishing app | A labelled input or textarea on useDraftProperty, with validation errors and the "Changed in another tab" note |
The item type:
import type { IItem } from '@seedprotocol/sdk'
export type Doc = IItem<any> & { title?: string; body?: string }2. Edits in other tabs
TextField handles this. While the user types, the input shows their unsaved text, which is saved after a 400 ms pause, on blur and on unmount. Saved edits from another tab update every field the user isn't editing. If another tab saves the field the user is editing, useDraftProperty sets changedElsewhere and the field shows:
Changed in another tab. Your edit will replace it.
When the user leaves the field, their text is saved over the other tab's value. That's last-write-wins, the only rule items have on one device (Conflict detection), and the warning tells the user before it happens.
Edits to different fields never interfere: each property is its own row.
3. Notice versions published from another device
A device only learns about another device's work through a sync, and the SDK doesn't tell you when a sync changed a value the user hasn't edited. useRemoteChanges fills that gap. When the editor opens it records when the document was last published, using getSeedPublishState's lastAttestedAtMs. After each completed sync it reads that time again, and if it moved, a newer version was published elsewhere.
import { useEffect, useState } from 'react'
import { getSeedPublishState } from '@seedprotocol/sdk'
import { useEasSyncProcessesState } from '@seedprotocol/react'
import { useItemPublishStatus } from '@seedprotocol/publish'
// Tracks whether a newer version of this document was published from another device
// since the editor opened (or since this device last published it).
export function useRemoteChanges(seedLocalId: string) {
const [seenAtMs, setSeenAtMs] = useState<number | null | undefined>(undefined)
const [latestAtMs, setLatestAtMs] = useState<number | null | undefined>(undefined)
const { records } = useEasSyncProcessesState()
const lastSyncAt = records?.find((r) => r.status === 'completed')?.completedAt ?? null
const { publishValue } = useItemPublishStatus(seedLocalId)
const publishedHere = publishValue === 'success'
// What was published when the editor opened, and again after this device publishes.
useEffect(() => {
let cancelled = false
getSeedPublishState({ seedLocalId }).then((state) => {
if (cancelled) return
setSeenAtMs(state.lastAttestedAtMs)
setLatestAtMs(state.lastAttestedAtMs)
})
return () => {
cancelled = true
}
}, [seedLocalId, publishedHere])
// What's published now, read again after every completed sync (in any tab).
useEffect(() => {
if (lastSyncAt == null) return
let cancelled = false
getSeedPublishState({ seedLocalId }).then((state) => {
if (!cancelled) setLatestAtMs(state.lastAttestedAtMs)
})
return () => {
cancelled = true
}
}, [seedLocalId, lastSyncAt])
return {
seenAtMs,
changedElsewhere: seenAtMs !== undefined && latestAtMs !== undefined && latestAtMs !== seenAtMs,
acknowledge: () => setSeenAtMs(latestAtMs),
}
}- Local edits don't move
lastAttestedAtMs: it only counts attested rows. A publish from this device does, so the hook takes a new baseline when one succeeds. useEasSyncProcessesStatereads the sync history table, so a sync run by the leader tab, or by the user's Sync now in any tab, re-runs the check in every tab.- After the sync, the newer published values are what the item shows, except where the user has an unpublished edit: the edit keeps showing, and a different synced value is recorded as a conflict (which edit wins). Step 4 shows them.
4. The editor
- Get the latest version syncs now and lists the fields where a published version conflicts with the user's unpublished edit, with
syncAndListConflicts. For each one the user keeps their edit, which the next publish sends, or takes the published value, which drops the edit. - Publish this version first checks
publishedElsewhere, which syncs and compares the publish time with the one the editor started from. If another device published since, the user confirms before replacing it.
import { useState } from 'react'
import { useActiveAccount } from 'thirdweb/react'
import { useItem } from '@seedprotocol/react'
import { PublishManager, useItemPublishStatus } from '@seedprotocol/publish'
import type { PropertyConflict } from '@seedprotocol/sdk'
import { publishedElsewhere } from './publishGuard'
import { settleConflict, syncAndListConflicts } from './syncConflicts'
import { TextField } from './TextField'
import { useIsOwned } from './useIsOwned'
import { useRemoteChanges } from './useRemoteChanges'
import type { Doc } from './types'
const RETRYABLE = [
'attestationFailureRecoverable',
'attestationFailureRecoverableDirectToEas',
'retractionFailureRecoverable',
]
export function DocEditor({ seedLocalId }: { seedLocalId: string }) {
const { item, isLoading } = useItem({ modelName: 'Document', seedLocalId })
const doc = item as Doc | undefined
const owned = useIsOwned(seedLocalId)
const account = useActiveAccount()
const remote = useRemoteChanges(seedLocalId)
const { publishProcess, publishValue, latestRecord } = useItemPublishStatus(seedLocalId)
const [conflicts, setConflicts] = useState<PropertyConflict[]>([])
const [notice, setNotice] = useState<string | null>(null)
const [publishError, setPublishError] = useState<string | null>(null)
if (isLoading || owned === undefined) return <p>Loading…</p>
if (!doc) return <p>Document not found</p>
if (!owned) return <p>This document belongs to another account, so it's read-only here.</p>
const retryable = RETRYABLE.includes(publishValue ?? '')
const failed = publishValue === 'failure' || retryable
async function syncNow() {
setNotice(null)
try {
setConflicts(await syncAndListConflicts(doc!))
} catch {
setNotice("Couldn't sync. Check your connection and try again.")
}
}
async function settle(conflict: PropertyConflict, choice: 'local' | 'remote') {
try {
await settleConflict(doc!, conflict, choice)
setConflicts(await doc!.conflicts())
} catch (err) {
setNotice(err instanceof Error ? err.message : String(err))
}
}
async function publish() {
setNotice(null)
setPublishError(null)
let elsewhere: boolean
try {
elsewhere = await publishedElsewhere(doc!, remote.seenAtMs ?? null)
} catch {
setNotice("Couldn't check for newer versions. Check your connection and try again.")
return
}
if (
elsewhere &&
!window.confirm(
'A newer version of this document was published from another device. ' +
'Publishing now replaces it with what you see here. Publish anyway?',
)
) {
return
}
try {
await doc!.publish()
} catch (err) {
setPublishError(err instanceof Error ? err.message : String(err))
}
}
function retry() {
setPublishError(null)
PublishManager.retryAttestations(seedLocalId)
}
return (
<main>
{remote.changedElsewhere && (
<p role="status">
A newer version was published from another device and is shown below.{' '}
<button type="button" onClick={remote.acknowledge}>OK</button>
</p>
)}
{conflicts.map((c) => (
<p role="alert" key={c.propertyName}>
A version published from another device has a different {c.propertyName}. You're seeing
your unpublished edit.{' '}
<button type="button" onClick={() => settle(c, 'local')}>Keep mine</button>{' '}
<button type="button" onClick={() => settle(c, 'remote')}>Use the published one</button>
</p>
))}
<TextField seedLocalId={seedLocalId} propertyName="title" label="Title" />
<TextField seedLocalId={seedLocalId} propertyName="body" label="Text" multiline />
<button type="button" onClick={syncNow}>Get the latest version</button>{' '}
<button
type="button"
onClick={publish}
disabled={!account || !doc.title || publishProcess != null}
>
{publishProcess ? 'Publishing…' : 'Publish this version'}
</button>
{!account && <p>Connect a wallet to publish.</p>}
{failed ? (
<p role="alert">
Publishing failed: {latestRecord?.errorMessage ?? publishError ?? 'unknown error'}{' '}
{retryable && (
<button type="button" onClick={retry}>
Retry
</button>
)}
</p>
) : (
publishError && <p role="alert">{publishError}</p>
)}
{notice && <p role="alert">{notice}</p>}
</main>
)
}publishedElsewhere and Get the latest version both call client.syncFromEas(), which throws when the indexer can't be reached. The editor catches that and shows a message instead of publishing blind. doc.publish() resolves when the publish finishes and rejects if it fails. The editor shows one message for a failed publish, the one saved in latestRecord, which is also there after a reload; any other rejection, such as another tab already publishing the document, shows as it is. Retry works as in Build a publishing app. Keep writing while offline shows how to queue a publish for when the network returns.
5. The app
The app lists the user's documents, wires up the sync triggers once the client is ready, and shows the sync status.
import { useEffect, useState } from 'react'
import { useCreateItem, useIsClientReady, useItem, useItems } from '@seedprotocol/react'
import { ConnectButton } from '@seedprotocol/publish/thirdweb'
import { DocEditor } from './DocEditor'
import { SyncStatus } from './SyncStatus'
import { keepInSync } from './syncTriggers'
import type { Doc } from './types'
let syncStarted = false
export function DocsApp() {
const [selectedId, setSelectedId] = useState<string | null>(null)
const { items: docs } = useItems({ modelName: 'Document', includeEas: true })
const { createItem, isLoading: creating } = useCreateItem()
const ready = useIsClientReady()
// Sync on reconnect and when the user comes back to the app (leader tab only).
useEffect(() => {
if (!ready || syncStarted) return
syncStarted = true
void keepInSync()
}, [ready])
const newDoc = async () => {
const doc = await createItem('Document', { title: 'Untitled' })
if (doc) setSelectedId(doc.seedLocalId)
}
return (
<>
<header>
<ConnectButton />
<SyncStatus />
</header>
<div style={{ display: 'flex', gap: '1rem' }}>
<nav>
<button onClick={newDoc} disabled={creating}>New document</button>
<ul>
{docs.map((d) => (
<DocRow key={d.seedLocalId} seedLocalId={d.seedLocalId} onSelect={setSelectedId} />
))}
</ul>
</nav>
{selectedId ? <DocEditor key={selectedId} seedLocalId={selectedId} /> : <p>Select a document</p>}
</div>
</>
)
}
function DocRow({ seedLocalId, onSelect }: { seedLocalId: string; onSelect: (id: string) => void }) {
const { item } = useItem({ modelName: 'Document', seedLocalId })
return (
<li>
<button onClick={() => onSelect(seedLocalId)}>{(item as Doc | undefined)?.title || 'Untitled'}</button>
</li>
)
}SyncStatus renders before the client is ready, since SeedClientGate mounts its children early. Until then the EAS sync hooks return undefined, and SyncStatus renders nothing.
main.tsx is the one from Publishing your first item, rendering DocsApp:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SeedClientGate } from '@seedprotocol/react'
import { PublishProvider } from '@seedprotocol/publish/thirdweb'
import './publish'
import { config } from './seed'
import { DocsApp } from './DocsApp'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<PublishProvider>
<SeedClientGate initConfig={{ config }}>
<DocsApp />
</SeedClientGate>
</PublishProvider>
</StrictMode>,
)Complete code
seed.schema.json
src/
main.tsx step 5
DocsApp.tsx step 5
DocEditor.tsx step 4
useRemoteChanges.ts step 3
types.ts step 1
seed.ts Add Seed to your app
publish.ts Publishing your first item
TextField.tsx Build a publishing app
useDraftProperty.ts Editing items optimistically
useIsOwned.ts Editing items optimistically
syncTriggers.ts Sync a user's data to another device (keepInSync)
SyncStatus.tsx Sync a user's data to another device
syncConflicts.ts Sync a user's data to another device (syncAndListConflicts, settleConflict)
publishGuard.ts Conflict detection (publishedElsewhere)To try it, open the app in two tabs, select the same document in both, and type in one while the other has the same field focused. For the device half, connect the same account on two browsers (or a normal and a private window), publish from one, and press Get the latest version on the other. To see a conflict, edit the title on the second browser before pressing it.
What isn't possible
Be upfront with your users about these. None of them can be worked around inside the SDK; Conflict detection explains the sync rules behind the merge and conflict rows.
| You might want | What Seed does |
|---|---|
| Several people editing one document | A document is owned by the account that publishes it. Other accounts' copies are read-only, and only the owner can publish or revoke it. There's no sharing, invite or role model |
| Live co-editing, keystroke by keystroke | Tabs on one device share a database, but devices only exchange published versions, through EAS sync. Nothing is pushed: a device sees a new version only when it syncs |
| Unpublished edits on the user's other devices | Drafts and unpublished edits never leave the device they were written on |
| Merging concurrent edits | No CRDT or three-way merge. Within a field, the last save wins on one device, and the newest attestation among the owner's wins across devices. An unpublished edit stays over a synced value, and the user picks between them |
| Conflict detection | Between tabs, none. Across devices, the SDK records a conflict when a sync brings in a value that differs from an unpublished edit. Nothing compares versions before a publish, and nothing throws: the publish check in this recipe is your app's own |
| Presence, cursors, "someone is typing" | Nothing |
| Private documents | Drafts stay on the device, unencrypted in the browser's storage. Anything published is public and permanent: anyone can read every published version, including after you unpublish |
What you can do instead
- Let others read it. A published document is readable by any app: share its Seed UID and read it with
getSeedfrom@seedprotocol/query(Read published Seed data from another app or a server), whose changelog also shows every published version. - Let others suggest changes. Another account can publish its own item that points at your document through a relation, such as a
Suggestionwith adocumentrelation and the proposed text. The owner reviews suggestions and applies them by editing. Each suggestion stays attributed to the account that wrote it. - Publish unattended for the owner, for example from a server, with a session key the owner grants. It publishes as the owner's account but can't revoke anything (Publish unattended with a session key).
What's left to you
- A rich-text editor. Any editor that produces an HTML string can feed
useDraftProperty'sonChange. Sanitize the HTML before rendering it, as in Reading and writing data. - Version history in the UI.
getSeed(seedUid, { include: 'data+changelog' })from@seedprotocol/querylists published versions and what changed in each.
Gotchas
- The "published elsewhere" check runs only after a sync. The SDK syncs on its own at start-up and after a wallet connects;
keepInSyncadds reconnects and returning to the tab. Between syncs, the editor can't know. - A conflict can show up after the list. Pressing Get the latest version saves the field the user was typing in, and an edit still waiting in the outbox when the sync runs records its conflict once it's applied. Pressing the button again lists it.
Related
A profile the user owns
A user profile that belongs to the user's account, not your app: edit it locally, publish it, and read it from any other app or server by the account's address.
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.