Seed Protocol
Recipes

Build a publishing app

A small blog app with authors and posts: a list that tracks each post's publish status, an editor that saves as you type, a cover image, publish and unpublish, and reading the published posts back from another app.

This recipe puts the blog from Getting started together into one working app. Authors write posts, each post has a cover image and an HTML body, and the list shows which posts are drafts, which are published, and which have changes that aren't published yet. Publishing and unpublishing happen from the editor, and a short script reads the published posts back the way any other app would.

You'll need:

  • A Vite + React app set up as in Add Seed to your app
  • Publishing set up as in Publishing your first item: @seedprotocol/publish and thirdweb installed, a thirdweb client ID, and that page's src/publish.ts
  • dompurify, to sanitize post bodies before rendering them: npm install dompurify

The schema

Two models: Post points at its Author through a relation, the "many" side, as Model relations between items recommends. The body is Html, so it has no length limit, and the cover is an Image. This file replaces your app's seed.schema.json, so set version above the one your app last ran: an edit at the same version replaces the SDK's copy of that version's schema file, and the SDK logs a warning that asks you to raise version (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 },
        "summary": { "type": "Text" },
        "body": { "type": "Html" },
        "cover": { "type": "Image" },
        "author": { "type": "Relation", "model": "Author", "required": true }
      }
    },
    "Author": {
      "properties": {
        "name": { "type": "Text", "required": true },
        "bio": { "type": "Text" }
      }
    }
  },
  "enums": {},
  "migrations": []
}

There's no published flag. Whether a post is published comes from its publish state, which the app reads in step 2.

1. Copy the helpers

The app uses these files from other pages unchanged:

FileFromWhat it does
src/seed.tsAdd Seed to your appThe client config
src/publish.tsPublishing your first itemRuns initPublish
src/useDraftProperty.tsEditing items optimisticallyHolds unsaved text while the user types and saves it after a pause, on blur and on unmount
src/useIsOwned.tsEditing items optimisticallyWhether the user may edit an item

Then describe the item fields for TypeScript, as in Typing items:

src/types.ts
import type { IItem } from '@seedprotocol/sdk'

export type Post = IItem<any> & {
  title?: string
  summary?: string
  body?: string
  cover?: string
  author?: string | null
}

export type Author = IItem<any> & { name?: string; bio?: string }

2. List posts with their publish status

A post can be in one of five states, and the list and the editor both show it:

StatusMeaning
DraftNever published
Publishing…A publish is running in this tab
PublishedPublished, and nothing has changed since
Published, with unpublished changesPublished, then edited. The edits are local until the next publish
UnpublishedPublished, then revoked. The Seed may be revoked before the rest of the post is (step 4)

usePublishState works it out from three sources. A live query on the post's own rows gives its Seed UID and revocation time, and re-runs whenever any of its values is saved, in any tab. getPublishPendingDiff lists the properties edited since the last publish. useItemPublishStatus says whether a publish is running.

src/usePublishState.ts
import { useEffect, useMemo, useState } from 'react'
import { getPublishPendingDiff } from '@seedprotocol/sdk'
import { useLiveQuery } from '@seedprotocol/react'
import { useItemPublishStatus } from '@seedprotocol/publish'

export type PublishStatus = 'draft' | 'publishing' | 'published' | 'changed' | 'unpublished'

type SeedRow = { uid: string | null; revoked_at: number | null; changed_at: number | null }

export function usePublishState(seedLocalId: string) {
  // Re-runs whenever this post's seed row or any of its values change, in any tab.
  const query = useMemo(
    () => (sql: any) => sql`
      SELECT s.uid, s.revoked_at,
        (SELECT MAX(COALESCE(m.updated_at, m.created_at)) FROM metadata m
          WHERE m.seed_local_id = s.local_id) AS changed_at
      FROM seeds s WHERE s.local_id = ${seedLocalId}`,
    [seedLocalId],
  )
  const row = useLiveQuery<SeedRow>(query)?.[0]
  const { publishProcess, publishValue, latestRecord } = useItemPublishStatus(seedLocalId)

  const seedUid = row?.uid || null
  const isRevoked = Boolean(row?.revoked_at)
  const [pending, setPending] = useState<string[]>([])

  useEffect(() => {
    if (!seedUid || isRevoked) {
      setPending([])
      return
    }
    let cancelled = false
    getPublishPendingDiff({ seedLocalId }).then(({ pendingProperties }) => {
      if (!cancelled) setPending(pendingProperties.map((p) => p.propertyName))
    })
    return () => {
      cancelled = true
    }
  }, [seedLocalId, seedUid, isRevoked, row?.changed_at])

  let status: PublishStatus
  if (publishProcess) status = 'publishing'
  else if (!seedUid) status = 'draft'
  else if (isRevoked) status = 'unpublished'
  else if (pending.length > 0) status = 'changed'
  else status = 'published'

  return { status, seedUid, pending, publishValue, latestRecord, isLoading: row === undefined }
}

export const STATUS_LABELS: Record<PublishStatus, string> = {
  draft: 'Draft',
  publishing: 'Publishing…',
  published: 'Published',
  changed: 'Published, with unpublished changes',
  unpublished: 'Unpublished',
}

The live query reads the SDK's internal tables, which aren't a public API; Querying with liveQuery lists the columns that are safe to rely on. Here it only serves as a change signal and to read two columns of the post's own row.

The list takes the post IDs from useItems and renders each row with its own useItem, so a renamed post updates in place. That's the pattern from Building a list view that updates in real time. includeEas: true keeps posts in the list once they're published.

src/PostList.tsx
import { useCreateItem, useItem, useItems } from '@seedprotocol/react'
import { STATUS_LABELS, usePublishState } from './usePublishState'
import type { Post } from './types'

type Props = { selectedId: string | null; onSelect: (seedLocalId: string) => void }

export function PostList({ selectedId, onSelect }: Props) {
  const { items, isLoading } = useItems({ modelName: 'Post', includeEas: true })
  const { createItem, isLoading: creating } = useCreateItem()

  const newPost = async () => {
    const post = await createItem('Post', { title: 'Untitled' })
    if (post) onSelect(post.seedLocalId)
  }

  return (
    <nav>
      <button onClick={newPost} disabled={creating}>New post</button>
      {isLoading ? (
        <p>Loading…</p>
      ) : items.length === 0 ? (
        <p>No posts yet</p>
      ) : (
        <ul>
          {items.map((item) => (
            <PostRow
              key={item.seedLocalId}
              seedLocalId={item.seedLocalId}
              selected={item.seedLocalId === selectedId}
              onSelect={onSelect}
            />
          ))}
        </ul>
      )}
    </nav>
  )
}

function PostRow({
  seedLocalId,
  selected,
  onSelect,
}: {
  seedLocalId: string
  selected: boolean
  onSelect: (seedLocalId: string) => void
}) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  const { status } = usePublishState(seedLocalId)

  return (
    <li>
      <button onClick={() => onSelect(seedLocalId)} aria-current={selected}>
        {post?.title || 'Untitled'}
      </button>{' '}
      <small data-status={status}>{STATUS_LABELS[status]}</small>
    </li>
  )
}

3. Build the editor

Text fields

Every text field, the HTML body included, goes through useDraftProperty. Holding unsaved text in the input saves once per pause instead of once per keystroke, and tells the user when another tab changed the field while they were typing (why).

src/TextField.tsx
import { useDraftProperty } from './useDraftProperty'

type Props = { seedLocalId: string; propertyName: string; label: string; multiline?: boolean }

export function TextField({ seedLocalId, propertyName, label, multiline }: Props) {
  const { property, value, onChange, onBlur, changedElsewhere } = useDraftProperty(
    seedLocalId,
    propertyName,
  )
  const errors = property?.saveValidationErrors ?? []
  const inputProps = {
    name: propertyName,
    value,
    'aria-invalid': errors.length > 0,
    onBlur,
  }

  return (
    <div>
      <label>
        {label}
        {multiline ? (
          <textarea {...inputProps} rows={10} onChange={(e) => onChange(e.target.value)} />
        ) : (
          <input {...inputProps} onChange={(e) => onChange(e.target.value)} />
        )}
      </label>
      {errors.map((error) => (
        <p key={error.code} role="alert">{error.message}</p>
      ))}
      {changedElsewhere && <p role="status">Changed in another tab. Your edit will replace it.</p>}
    </div>
  )
}

The author

A <select> changes in one step, so it assigns the relation directly. A relation holds an ID string: the author's seedLocalId, or its seedUid for an author synced from another device, so the select matches either. Each option renders through its own useItem, so a renamed author updates in the list.

src/AuthorSelect.tsx
import { useState } from 'react'
import { useCreateItem, useItem, useItems } from '@seedprotocol/react'
import type { Author, Post } from './types'

export function AuthorSelect({ post }: { post: Post }) {
  const { items: authors } = useItems({ modelName: 'Author', includeEas: true })
  const { createItem, isLoading } = useCreateItem()
  const [newName, setNewName] = useState('')

  // A relation holds a seedLocalId, or a seedUid for an author synced from EAS.
  const selected = authors.find(
    (a) => post.author && (a.seedLocalId === post.author || a.seedUid === post.author),
  )

  const addAuthor = async () => {
    const name = newName.trim()
    if (!name) return
    const author = await createItem('Author', { name })
    if (author) {
      post.author = author.seedLocalId
      setNewName('')
    }
  }

  return (
    <fieldset>
      <label>
        Author
        <select
          name="author"
          value={selected?.seedLocalId ?? ''}
          onChange={(e) => {
            post.author = e.target.value || null
          }}
        >
          <option value="">No author</option>
          {authors.map((a) => (
            <AuthorOption key={a.seedLocalId} seedLocalId={a.seedLocalId} />
          ))}
        </select>
      </label>
      <input
        name="new-author"
        placeholder="New author's name"
        value={newName}
        onChange={(e) => setNewName(e.target.value)}
      />
      <button type="button" onClick={addAuthor} disabled={isLoading || !newName.trim()}>
        Add author
      </button>
    </fieldset>
  )
}

function AuthorOption({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Author', seedLocalId })
  return <option value={seedLocalId}>{(item as Author | undefined)?.name || 'Unnamed'}</option>
}

New authors are local drafts like posts. They don't need publishing on their own: publishing a post publishes the unpublished author it points at, in the same run (Publish related items together).

The cover image

Assign the picked file and show it with SeedImage. The SDK stores each upload under a unique name, so two posts' photo.png don't overwrite each other. See Working with file and image properties for what else you can assign and how images from other devices are fetched.

src/CoverField.tsx
import { SeedImage, useItemProperty } from '@seedprotocol/react'

export function CoverField({ seedLocalId }: { seedLocalId: string }) {
  const { property } = useItemProperty({ seedLocalId, propertyName: 'cover' })

  return (
    <div>
      <label>
        Cover image
        <input
          type="file"
          accept="image/*"
          disabled={!property}
          onChange={(e) => {
            const file = e.target.files?.[0]
            if (file && property) property.value = file
          }}
        />
      </label>
      {property && (
        <div style={{ width: 480, maxWidth: '100%' }}>
          <SeedImage
            imageProperty={property}
            width={480}
            alt="Cover"
            style={{ width: '100%', height: 'auto' }}
          />
        </div>
      )}
    </div>
  )
}

The editor

The editor checks ownership first, and shows a read-only view of posts that belong to another account. An assignment to someone else's post fails without an error and the post shows the stored value again, so inputs for it would just snap back; checking before the inputs appear is clearer. A Preview button switches to the same view.

src/PostEditor.tsx
import { useState } from 'react'
import { useItem } from '@seedprotocol/react'
import { AuthorSelect } from './AuthorSelect'
import { CoverField } from './CoverField'
import { PostView } from './PostView'
import { PublishBar } from './PublishBar'
import { TextField } from './TextField'
import { useIsOwned } from './useIsOwned'
import type { Post } from './types'

export function PostEditor({ seedLocalId }: { seedLocalId: string }) {
  const { item, isLoading } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  const owned = useIsOwned(seedLocalId)
  const [preview, setPreview] = useState(false)

  if (isLoading || owned === undefined) return <p>Loading…</p>
  if (!post) return <p>Post not found</p>
  if (!owned) return <PostView seedLocalId={seedLocalId} />

  return (
    <main>
      <button type="button" onClick={() => setPreview((p) => !p)}>
        {preview ? 'Edit' : 'Preview'}
      </button>
      {preview ? (
        <PostView seedLocalId={seedLocalId} />
      ) : (
        <form onSubmit={(e) => e.preventDefault()}>
          <TextField seedLocalId={seedLocalId} propertyName="title" label="Title" />
          <TextField seedLocalId={seedLocalId} propertyName="summary" label="Summary" />
          <AuthorSelect post={post} />
          <CoverField seedLocalId={seedLocalId} />
          <TextField seedLocalId={seedLocalId} propertyName="body" label="Body (HTML)" multiline />
        </form>
      )}
      <PublishBar seedLocalId={seedLocalId} />
    </main>
  )
}

4. Publish and unpublish

PublishBar shows the status and the actions that fit it:

  • Publish for a draft, Publish changes for a published post with edits, Publish again for an unpublished one once its unpublish has succeeded in this session. All three call post.publish(), which resolves when the publish has finished and rejects when it fails; the status line follows its progress. A publish attests only what changed since the last one; an unpublished post comes back as a new Seed with a new UID.
  • Unpublish for a published post. It asks for confirmation, then calls post.unpublish(), which revokes the post's attestations from the account that published them. The author and the cover image are separate items, and stay published. The button stays while the unpublish may be incomplete; see below.
src/PublishBar.tsx
import { useState } from 'react'
import { useActiveAccount } from 'thirdweb/react'
import { useItem } from '@seedprotocol/react'
import { PublishManager, getPublishConfig } from '@seedprotocol/publish'
import { STATUS_LABELS, usePublishState } from './usePublishState'
import type { Post } from './types'

const RETRYABLE = [
  'attestationFailureRecoverable',
  'attestationFailureRecoverableDirectToEas',
  'retractionFailureRecoverable',
]

export function PublishBar({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  const account = useActiveAccount()
  const { status, seedUid, publishValue, latestRecord } = usePublishState(seedLocalId)
  const [notice, setNotice] = useState<string | null>(null)
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [finished, setFinished] = useState(false)

  if (!post) return null
  const complete = Boolean(post.title && post.author)
  const retryable = RETRYABLE.includes(publishValue ?? '')
  const failed = publishValue === 'failure' || retryable

  async function publish() {
    if (!post) return
    setNotice(null)
    setFinished(false)
    try {
      await post.publish()
    } catch (err) {
      setNotice(err instanceof Error ? err.message : String(err))
    }
  }

  function retry() {
    setNotice(null)
    PublishManager.retryAttestations(seedLocalId)
  }

  async function runUnpublish() {
    if (!post) return
    setBusy(true)
    setError(null)
    try {
      await post.unpublish()
      setFinished(true)
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err))
    } finally {
      setBusy(false)
    }
  }

  async function confirmAndUnpublish() {
    const ok = window.confirm(
      'Unpublish this post? Seed apps and feeds will stop showing it, but the revoked ' +
        'attestations and the files on Arweave stay readable. This cannot be undone.',
    )
    if (ok) await runUnpublish()
  }

  const unpublished = status === 'unpublished'
  const showUnpublish =
    status === 'published' || status === 'changed' || (unpublished && (error !== null || !finished))
  // Publish again only once the unpublish is known to be complete, so no old attestations stay live.
  const showPublish = status === 'draft' || status === 'changed' || (unpublished && finished && error === null)
  const started = error !== null || unpublished
  const unpublishLabel = error ? 'Retry unpublish' : unpublished ? 'Finish unpublishing' : 'Unpublish'

  const explorerUrl = getPublishConfig().easChain.explorerUrl

  return (
    <section>
      <p>
        Status: <strong data-testid="status">{STATUS_LABELS[status]}</strong>
        {seedUid && status !== 'unpublished' && (
          <>
            {' '}
            <a href={`${explorerUrl}/attestation/view/${seedUid}`} target="_blank" rel="noreferrer">
              View on EAS Scan
            </a>
          </>
        )}
      </p>

      {showPublish && (
        <button type="button" onClick={publish} disabled={!account || !complete}>
          {status === 'changed' ? 'Publish changes' : status === 'unpublished' ? 'Publish again' : 'Publish'}
        </button>
      )}
      {showUnpublish && (
        <button type="button" onClick={started ? runUnpublish : confirmAndUnpublish} disabled={busy}>
          {busy ? 'Unpublishing…' : unpublishLabel}
        </button>
      )}

      {!account && <p>Connect a wallet to publish.</p>}
      {!complete && <p>Add a title and an author before publishing.</p>}
      {failed ? (
        <p role="alert">
          Publishing failed: {latestRecord?.errorMessage ?? notice ?? 'unknown error'}{' '}
          {retryable && (
            <button type="button" onClick={retry}>
              Retry
            </button>
          )}
        </p>
      ) : (
        notice && <p role="alert">{notice}</p>
      )}
      {error && <p role="alert">{error}</p>}
    </section>
  )
}

Publishing needs a connected wallet, a title and an author: the schema marks title and author as required, which a local save doesn't check but a publish does. A failed publish shows the message saved in latestRecord; any other rejection, such as another tab already publishing the post, shows as it is. Retry appears when the uploads finished but the attestations failed, and attests again without uploading. It also appears when the attestations were made but revoking a retracted property's attestations failed, and sends the revocations again (Handle failures and resume).

If unpublish() fails partway, the Seed is already revoked but some of the post's attestations are still live. A sync can then mark the post revoked, so the status reads "Unpublished" before the unpublish has finished. After a failure the button reads Retry unpublish until a call succeeds, whatever the status says. When the status is "Unpublished" and this session hasn't seen the call succeed, after a reload for example, the bar shows Finish unpublishing, because nothing on the post tells a complete unpublish from a partial one. Neither asks for confirmation again. Publish again stays hidden until one of them succeeds: republishing while the old attestations are partly live would leave them beside the new Seed. Calling unpublish() again is safe: it skips what EAS already shows as revoked and revokes the rest, and when everything is revoked it sends no transaction. It still runs its checks first, so the user has to be connected with the publishing account either way (Retrying).

Unpublishing doesn't delete anything. The revoked attestations, the cover image and the body on Arweave stay readable by anyone who has their IDs. See What isn't revoked and Permanence.

5. Read it back

In the app

PostView renders a post from the local database: the cover through SeedImage, the author through its own useItem, and the body through SeedHtml with DOMPurify. The SDK doesn't sanitize HTML, so never render a body without a sanitizer.

src/PostView.tsx
import DOMPurify from 'dompurify'
import { resolveSeedIdsFromRefString } from '@seedprotocol/sdk'
import { SeedHtml, SeedImage, useItem, useItemProperty } from '@seedprotocol/react'
import type { Author, Post } from './types'

export function PostView({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  const authorIds = post?.author ? resolveSeedIdsFromRefString(post.author) : {}
  const { item: authorItem } = useItem({ modelName: 'Author', ...authorIds })
  const author = authorItem as Author | undefined
  const { property: cover } = useItemProperty({ seedLocalId, propertyName: 'cover' })
  const { property: body } = useItemProperty({ seedLocalId, propertyName: 'body' })

  if (!post) return null

  return (
    <article>
      {cover && (
        <div style={{ width: 760, maxWidth: '100%' }}>
          <SeedImage
            imageProperty={cover}
            width={760}
            alt=""
            style={{ width: '100%', height: 'auto' }}
          />
        </div>
      )}
      <h1>{post.title || 'Untitled'}</h1>
      <p>By {author?.name ?? 'Unknown author'}</p>
      {post.summary && <p><em>{post.summary}</em></p>}
      <SeedHtml
        html={body?.value != null ? String(body.value) : undefined}
        sanitize={(raw) => DOMPurify.sanitize(raw)}
      />
    </article>
  )
}

From any other app

Published posts are public, so a different app, a static site build or a server can read them without the SDK, a wallet or your schema file. This script lists one account's posts with @seedprotocol/query, as in Read published Seed data from another app or a server:

scripts/read-posts.mjs
// node scripts/read-posts.mjs 0xYourPublishingAddress
import { queryBySchema } from '@seedprotocol/query'

const publisher = process.argv[2]
if (!publisher) throw new Error('Usage: node scripts/read-posts.mjs <publisher address>')

const { items: posts } = await queryBySchema('post', { attesters: [publisher], limit: 100 })

for (const post of posts) {
  const { title, summary, body, author, cover } = post.data
  console.log(`${post.seedUid}  ${typeof title === 'string' ? title : '(no title)'}`)
  if (typeof summary === 'string') console.log(`  ${summary}`)
  if (author && typeof author === 'object') console.log(`  by ${author.name}`)
  if (cover && typeof cover === 'object') console.log(`  cover: ${cover.arweaveUrl}`)
  if (typeof body === 'string') console.log(`  body: ${body.length} characters of HTML`)
}
process.exit(0)
npm install @seedprotocol/query
node scripts/read-posts.mjs 0xYourPublishingAccount

If your app isn't on the latest release, install query at the version your other @seedprotocol/* packages are on, for example npm install @seedprotocol/query@<that version> (one version for every package).

Pass the account that published the posts: with ConnectButton that's the user's ManagedAccount, the attester EAS Scan shows on each post (getPublishWallet()?.publisherAddress in the app). In the records, author is expanded into the author's values, cover into the image's Seed with an arweaveUrl, and body into the HTML text.

'post' covers every app's posts, so the script asks for one attester's, checks each field's type, and reads only the first 100; see Only one publisher's items and Page through results. Bodies are fetched from arweave.net first; How the SDK reads gives the gateway order.

Complete code

The files, and where each one comes from:

seed.schema.json          the schema above
src/
  main.tsx                below
  BlogApp.tsx             below
  seed.ts                 Add Seed to your app
  publish.ts              Publishing your first item
  types.ts                step 1
  useDraftProperty.ts     Editing items optimistically
  useIsOwned.ts           Editing items optimistically
  usePublishState.ts      step 2
  PostList.tsx            step 2
  TextField.tsx           step 3
  AuthorSelect.tsx        step 3
  CoverField.tsx          step 3
  PostEditor.tsx          step 3
  PublishBar.tsx          step 4
  PostView.tsx            step 5
scripts/
  read-posts.mjs          step 5

main.tsx wraps the app as Publishing your first item does: PublishProvider from @seedprotocol/publish/thirdweb replaces SeedProvider, and import './publish' runs initPublish before anything renders.

src/main.tsx
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 { BlogApp } from './BlogApp'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <PublishProvider>
      <SeedClientGate initConfig={{ config }}>
        <BlogApp />
      </SeedClientGate>
    </PublishProvider>
  </StrictMode>,
)
src/BlogApp.tsx
import { useState } from 'react'
import { ConnectButton } from '@seedprotocol/publish/thirdweb'
import { PostEditor } from './PostEditor'
import { PostList } from './PostList'

export function BlogApp() {
  const [selectedId, setSelectedId] = useState<string | null>(null)

  return (
    <>
      <header>
        <ConnectButton />
      </header>
      <div style={{ display: 'flex', gap: '1rem' }}>
        <PostList selectedId={selectedId} onSelect={setSelectedId} />
        {selectedId ? <PostEditor key={selectedId} seedLocalId={selectedId} /> : <p>Select a post</p>}
      </div>
    </>
  )
}

What's left out

On this page