Seed Protocol
Recipes

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.

In most apps a profile is a row in the app's database, and only that app can read it. Here the profile is an item the user publishes from their own account. Your app edits it, and any other app or server can look it up by the user's address and show the same name, bio and avatar, without asking your app.

You'll need:

What "owned by the user" means

The profile is owned by the account that publishes it. With ConnectButton, that's the user's ManagedAccount: it makes the profile's attestations, so it's recorded as the author, and only it can change or revoke the published profile (Identity and signing). Your app never holds that key. Other apps find the profile by that address.

On a device, the profile is editable when its publisher is one of the client's owned addresses, or when it's a draft that has never had a publisher, which the next wallet to connect claims (Ownership and drafts).

The schema

seed.schema.json
{
  "$schema": "https://seedprotocol.io/schemas/data-model/v1",
  "version": 1,
  "metadata": {
    "name": "profiles",
    "createdAt": "2026-10-15T00:00:00.000Z",
    "updatedAt": "2026-10-15T00:00:00.000Z"
  },
  "models": {
    "Profile": {
      "properties": {
        "displayName": { "type": "Text", "required": true },
        "bio": { "type": "Text" },
        "website": { "type": "Text" },
        "avatar": { "type": "Image" }
      }
    }
  },
  "enums": {},
  "migrations": []
}

It replaces your app's seed.schema.json. Because its name, profiles, is new to the app, it starts at version 1; raise version with every edit after that (Change the schema).

The model name matters more here than usual. A model's published items live under an EAS schema named after the model, profile for Profile, shared by every app on the chain, and so are the property names (display_name, bio, …). Another app that wants to read your users' profiles needs only the property names; one that uses the same names can show them without any mapping. Pick names you're happy to share, and don't rename them once published (Change a schema after you've shipped).

1. Copy the helpers

These come from other pages unchanged:

FileFrom
src/useDraftProperty.tsEditing items optimistically
src/TextField.tsxBuild a publishing app
src/usePublishState.tsBuild a publishing app
src/seed.tsAdd Seed to your app
src/publish.tsPublishing your first item
src/types.ts
import type { IItem } from '@seedprotocol/sdk'

export type Profile = IItem<any> & {
  displayName?: string
  bio?: string
  website?: string
  avatar?: string
}

2. Find the user's profile

There's no "current user's profile" lookup in the SDK, so find it: list the Profile items on the device, including published and synced ones, and take the newest one the user owns. isItemOwned applies the same rule as a save or a publish. The hook checks again when the user's addresses change, for example when a wallet connects.

src/useMyProfile.ts
import { useEffect, useState } from 'react'
import { isItemOwned } from '@seedprotocol/sdk'
import { useIsClientReady, useItems, useSeedAddressRevision } from '@seedprotocol/react'

// The seedLocalId of the user's profile: the newest Profile they own, or null if they have none.
// undefined while it's being worked out.
export function useMyProfileId(): string | null | undefined {
  const isReady = useIsClientReady()
  const addressRevision = useSeedAddressRevision()
  const { items, isLoading } = useItems({ modelName: 'Profile', includeEas: true })
  const ids = items.map((item) => item.seedLocalId).join(',')
  const [profileId, setProfileId] = useState<string | null | undefined>(undefined)

  useEffect(() => {
    if (!isReady || isLoading) return
    let cancelled = false
    ;(async () => {
      // useItems lists newest first, so the first owned one is the newest
      for (const seedLocalId of ids ? ids.split(',') : []) {
        if (await isItemOwned({ seedLocalId })) {
          if (!cancelled) setProfileId(seedLocalId)
          return
        }
      }
      if (!cancelled) setProfileId(null)
    })()
    return () => {
      cancelled = true
    }
  }, [isReady, isLoading, ids, addressRevision])

  return profileId
}

On a second device, the published profile arrives with the first sync after the user connects: ConnectButton sets the owned addresses, which starts a sync (Sync a user's data to another device). Unpublished edits stay on the device they were made on.

3. Edit it

The text fields use TextField, which holds unsaved text while the user types (why). The avatar is an Image property. The SDK stores each upload under a unique name, so it can't overwrite another image with the same filename.

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

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

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

The editor shows the publish status from usePublishState and a publish button, as the publishing app's PublishBar does. Publishing needs a connected wallet and a name: the schema marks displayName as required, which a publish checks and a local save doesn't. A failed publish shows the message saved in latestRecord; any other rejection, such as another tab already publishing the profile, shows as it is. Retry appears when the uploads finished but the attestations failed, and attests again without uploading, or when revoking a retracted property failed, and sends the revocation again (Handle failures and resume).

src/ProfileEditor.tsx
import { useState } from 'react'
import { useActiveAccount } from 'thirdweb/react'
import { useItem } from '@seedprotocol/react'
import { PublishManager } from '@seedprotocol/publish'
import { AvatarField } from './AvatarField'
import { TextField } from './TextField'
import { STATUS_LABELS, usePublishState } from './usePublishState'
import type { Profile } from './types'

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

export function ProfileEditor({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Profile', seedLocalId })
  const profile = item as Profile | undefined
  const account = useActiveAccount()
  const { status, publishValue, latestRecord } = usePublishState(seedLocalId)
  const [notice, setNotice] = useState<string | null>(null)

  if (!profile) return <p>Loading…</p>
  const retryable = RETRYABLE.includes(publishValue ?? '')
  const failed = publishValue === 'failure' || retryable

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

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

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <AvatarField seedLocalId={seedLocalId} />
      <TextField seedLocalId={seedLocalId} propertyName="displayName" label="Name" />
      <TextField seedLocalId={seedLocalId} propertyName="bio" label="Bio" multiline />
      <TextField seedLocalId={seedLocalId} propertyName="website" label="Website" />

      <p>
        Status: <strong data-testid="status">{STATUS_LABELS[status]}</strong>
      </p>
      {(status === 'draft' || status === 'changed' || status === 'unpublished') && (
        <button type="button" onClick={publish} disabled={!account || !profile.displayName}>
          {status === 'changed' ? 'Publish changes' : status === 'unpublished' ? 'Publish again' : 'Publish profile'}
        </button>
      )}

      {!account && <p>Connect a wallet to publish.</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>
      )}
    </form>
  )
}

Publishing the profile uploads the avatar to Arweave and attests every property from the user's account. Later publishes attest only what changed. Everything published is public and permanent: unpublishing revokes the attestations, but the old values and the avatar stay readable (Permanence). Say so next to the Publish button.

4. The app

The app creates a profile when the user has none, and shows the address other apps will look the profile up by. That's the publishing account, getPublishWallet()?.publisherAddress once a wallet is connected.

src/ProfileApp.tsx
import { useCreateItem, useSeedAddressRevision } from '@seedprotocol/react'
import { getPublishWallet } from '@seedprotocol/publish'
import { ConnectButton } from '@seedprotocol/publish/thirdweb'
import { ProfileEditor } from './ProfileEditor'
import { useMyProfileId } from './useMyProfile'

export function ProfileApp() {
  const profileId = useMyProfileId()
  const { createItem, isLoading: creating } = useCreateItem()

  return (
    <>
      <header>
        <ConnectButton />
        <PublisherAddress />
      </header>
      {profileId === undefined ? (
        <p>Loading…</p>
      ) : profileId === null ? (
        <button
          disabled={creating}
          onClick={() => createItem('Profile', { displayName: 'New user' })}
        >
          Create your profile
        </button>
      ) : (
        <ProfileEditor key={profileId} seedLocalId={profileId} />
      )}
    </>
  )
}

// The address other apps look the profile up by: the account that publishes it.
function PublisherAddress() {
  useSeedAddressRevision() // re-render when a wallet connects or disconnects
  const wallet = getPublishWallet()
  const address = wallet?.publisherAddress ?? wallet?.signer.address
  return address ? <p>Your profile address: <code>{address}</code></p> : null
}

The app's files, and where each one comes from:

seed.schema.json          the schema above
src/
  main.tsx                Publishing your first item
  seed.ts                 Add Seed to your app
  publish.ts              Publishing your first item
  types.ts                step 1
  useDraftProperty.ts     Editing items optimistically
  TextField.tsx           Build a publishing app
  usePublishState.ts      Build a publishing app
  useMyProfile.ts         step 2
  AvatarField.tsx         step 3
  ProfileEditor.tsx       step 3
  ProfileApp.tsx          step 4

main.tsx is the one from Publishing your first item, with ProfileApp in place of BlogApp.

5. Read the profile from any other app

Another app only needs the user's address. This module finds the account's newest published profile with @seedprotocol/eas and reads its values with @seedprotocol/query. It doesn't need the SDK, a wallet or your schema file.

npm install @seedprotocol/eas @seedprotocol/query

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

server/profiles.mjs
import { registerNodeEasPlatform } from '@seedprotocol/eas/node'
import { getEasSchemaUidForExactDefinition, getSeedsFromSchemaUids } from '@seedprotocol/eas'
import { getSeed } from '@seedprotocol/query'

registerNodeEasPlatform()

const NO_REF = '0x0000000000000000000000000000000000000000000000000000000000000000'
let profileSchemaUid

export async function getProfile(address) {
  // A model's Seed schema is `bytes32 <EAS name>`
  profileSchemaUid ??= await getEasSchemaUidForExactDefinition('bytes32 profile')
  if (!profileSchemaUid) return null // no Profile has been published on this chain yet

  // The account's attestations under `bytes32 profile`, newest first, revoked ones left out.
  const attestations = await getSeedsFromSchemaUids({
    schemaUids: [profileSchemaUid],
    addresses: [address],
  })
  const seeds = attestations.filter((a) => a.refUID === NO_REF)

  for (const seed of seeds) {
    const profile = await getSeed(seed.id)
    if (!profile) continue // no Version left, or every one was revoked
    const { displayName, bio, website, avatar } = profile.data
    return {
      address: profile.attester,
      seedUid: profile.seedUid,
      displayName: typeof displayName === 'string' ? displayName : null,
      bio: typeof bio === 'string' ? bio : null,
      website: typeof website === 'string' ? website : null,
      avatarUrl:
        avatar && typeof avatar === 'object' && typeof avatar.arweaveUrl === 'string'
          ? avatar.arweaveUrl
          : null,
    }
  }
  return null
}

getSeedsFromSchemaUids asks EAS for one account's attestations under bytes32 profile in a single request, with each one's refUID (Raw attestations with @seedprotocol/eas). The filter keeps only Seeds because a relation property named profile is attested under the same schema; its attestations point at a Version through refUID, while a Seed's refUID is all zeros. queryBySchema('profile', { attesters: [address] }) doesn't filter on refUID, so it would list those too. avatarUrl is on the first read gateway, arweave.net by default (How the SDK reads).

Serve it as JSON so browser apps can use it too:

server/index.mjs
import { createServer } from 'node:http'
import { getProfile } from './profiles.mjs'

createServer(async (req, res) => {
  const match = /^\/profiles\/(0x[0-9a-fA-F]{40})$/.exec(req.url ?? '')
  if (!match) return res.writeHead(404).end()
  try {
    const profile = await getProfile(match[1])
    if (!profile) return res.writeHead(404).end()
    res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(profile))
  } catch (err) {
    console.error(err)
    res.writeHead(502).end()
  }
}).listen(3002, () => console.log('http://localhost:3002/profiles/0x…'))
node server/index.mjs
curl http://localhost:3002/profiles/0xTheUsersAccount

getProfile returns null, and the endpoint 404, until the account has published a profile. Records come from the EAS indexer, which can take a little while to pick up a new publish.

In another Seed app

An app that runs the SDK can also keep other users' profiles in its own database: add their addresses as watched addresses, include a Profile model with the same property names in its schema, and let a sync bring the published profiles in. They're read-only there. See Sync a user's data to another device.

What's covered and what's left to you

The recipe handles creating, editing and publishing one profile per account, finding it again after a reload or on another device, and reading it from anywhere by address.

Left to you:

  • Length limits. Text holds up to 255 characters. For a shorter bio, add "validation": { "maxLength": 160 } to bio in the schema; TextField shows the error (Add validation that's enforced).
  • Checking the website URL. Text has no URL type. Since 0.7.4 validation.pattern is enforced on Text, so you can add one, such as "validation": { "pattern": "^https?://" }, to website.
  • Several profiles per account. If a user ends up with two (see below), let them pick one and unpublish the other.
  • Showing profiles next to content. Content and profiles from the same account share an attester, so look the profile up with the item's attester.

Gotchas

  • Two profiles for one user. A profile created on a new device before its first sync finishes is a second profile. The hook then picks the newest. To avoid it, wait for the first sync after connecting before offering Create your profile.
  • Images on the reading side are URLs, not files. avatarUrl is an Arweave gateway URL. Arweave is public, so anyone with the URL can fetch the image, now and later.
  • The address isn't the sign-in address. With ConnectButton the profile's author is the ManagedAccount, not the in-app wallet's own address. Look profiles up by the attester you see on EAS Scan.

On this page