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:
- A Vite + React app set up as in Add Seed to your app
- Publishing set up as in Publishing your first item:
src/publish.tsand thePublishProviderinsrc/main.tsx - Node, for the lookup in step 5
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
{
"$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:
| File | From |
|---|---|
src/useDraftProperty.ts | Editing items optimistically |
src/TextField.tsx | Build a publishing app |
src/usePublishState.ts | Build a publishing app |
src/seed.ts | Add Seed to your app |
src/publish.ts | Publishing your first item |
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.
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.
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).
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.
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 4main.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/queryIf 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).
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:
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/0xTheUsersAccountgetProfile 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 }tobioin the schema;TextFieldshows the error (Add validation that's enforced). - Checking the website URL.
Texthas no URL type. Since 0.7.4validation.patternis enforced onText, so you can add one, such as"validation": { "pattern": "^https?://" }, towebsite. - 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.
avatarUrlis 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
ConnectButtonthe 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.
Related
- Concepts: Identity and signing, Ownership and drafts, Permanence, The data model
- Guides: Editing items optimistically, Working with file and image properties, Read published Seed data from another app or a server, Sync a user's data to another device
- Reference: @seedprotocol/eas, @seedprotocol/query, Publish components and hooks
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.
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.