Define a schema and add to it
Write a seed.schema.json whose names, types and rules hold up when you publish, then add models and properties by editing the file, raising its version and restarting.
Names, types and validation rules are hard to change once items exist, so choose them before you have data, then grow the schema by adding to it. Renames, type changes and anything else that has to account for existing items are in Change a schema after you've shipped.
You'll need: a project set up as in Add Seed to your app, with seed.schema.json passed to the client as config.schema (Your first schema). The examples use the tutorial's blog schema with Post and Author.
Name things so they survive publishing
When you publish, each model and property is attested under an EAS schema named after its EAS name, which the exported easName function makes: BlogPost becomes blog_post, HTMLPage becomes html_page, coverURL becomes cover_url (What's published where). When the app syncs, the SDK matches names from EAS against yours loosely by default, ignoring case, separators, a plural s and a relation's _id, and stores what it finds under your names; nameMatch sets how loosely.
| Name | Rule | Good | Avoid |
|---|---|---|---|
Schema (metadata.name) | Not published, so any name works | blog, myApp, my-app | |
| Model | PascalCase words | Post, BlogPost, HTMLPage, Post2 | Two models that publish under the same name, such as HTMLPage and HtmlPage |
| Property | camelCase words | title, publishedAt, coverURL, isbn13 | Two properties of one model that publish under the same name, such as coverUrl and coverURL; my-title |
Why each rule matters:
- Names that publish the same can't be told apart. EAS only sees the EAS name, so two models (or two properties of one model) with the same EAS name share an EAS schema, and sync can't tell which one an attestation belongs to. The SDK logs a warning when it loads a schema like that, but loads it anyway.
- Older SDKs published some names differently (
HTMLPageashtmlpage). Sync still finds those unlessnameMatchis'exact'. See Where your schema lives. - Hyphens and spaces in model and property names are turned into
_on EAS, so they publish. In code they can't be used as identifiers (post['my-title']), which is reason enough to avoid them.
Two more rules:
- Don't reuse names the SDK manages. Item fields such as
createdAt,updatedAt,type,publisherandseedUidare reserved, and so are names ending inIdorIds, which the SDK uses to store relations and files. The full list is in Reserved property names. The SDK's internal schema already has models namedSeed,Version,Metadata,Image,FileandHtml, so don't give your own models those names. - Don't use names that publish as a reserved Solidity word. EAS can't register a schema named
event,view,returnsor another word Solidity's ABI reserves, so the SDK refuses a schema with a model or property whose EAS name is one:client.initrejects with a message naming it. AListofRelationis exempt, since it publishes with anIdssuffix. The list is in Reserved Solidity words. - Pick names you can keep. After you publish, a renamed property's values stay under the old name. See Rename a property.
The SDK checks names only when it loads your schema: it warns about EAS name collisions and refuses reserved Solidity words. To fail a build instead, and to check the other rules, test the file yourself:
import { easName } from '@seedprotocol/sdk'
import type { SchemaFileInput } from '@seedprotocol/sdk'
const IDENTIFIER = /^[A-Za-z][A-Za-z0-9]*$/
const INTERNAL_MODELS = ['Seed', 'Version', 'Metadata', 'Image', 'File', 'Html']
// The list in the ModelProperty reference, under "Reserved property names"
const RESERVED = [
'localId', 'uid', 'seedLocalId', 'seedUid', 'schemaUid', 'attestationCreatedAt',
'attestationRaw', 'createdAt', 'updatedAt', 'lastVersionPublishedAt', 'latestVersionLocalId',
'latestVersionUid', 'lastLocalUpdateAt', 'modelName', 'refSeedType', 'refValueType',
'refResolvedValue', 'refResolvedDisplayValue', 'type', 'src', 'alt', 'versionLocalId',
'versionsCount', 'versionUid', '_markedForDeletion', 'publisher', 'revokedAt',
]
// The list in the ModelProperty reference, under "Reserved Solidity words"
const ABI_WORDS = [
'constructor', 'error', 'event', 'fallback', 'function', 'receive', 'struct', 'calldata',
'memory', 'storage', 'payable', 'indexed', 'tuple', 'returns', 'constant', 'external',
'internal', 'private', 'public', 'pure', 'view', 'override',
]
// Names in `names` that publish under the same EAS name as an earlier one
function collisions(names: string[]): string[] {
const seen = new Map<string, string>()
const problems: string[] = []
for (const name of names) {
const published = easName(name)
const other = seen.get(published)
if (other) problems.push(`${other} and ${name} both publish as "${published}"`)
else seen.set(published, name)
}
return problems
}
export function checkSchemaNames(schema: SchemaFileInput): string[] {
const problems: string[] = []
const modelNames = Object.keys(schema.models)
problems.push(...collisions(modelNames))
for (const modelName of modelNames) {
if (!IDENTIFIER.test(modelName)) problems.push(`model "${modelName}" isn't an identifier`)
if (INTERNAL_MODELS.includes(modelName)) problems.push(`model "${modelName}" is an SDK model`)
if (ABI_WORDS.includes(easName(modelName))) problems.push(`model "${modelName}" publishes as a Solidity word`)
const properties = schema.models[modelName].properties
const propertyNames = Object.keys(properties)
problems.push(...collisions(propertyNames).map((p) => `${modelName}: ${p}`))
for (const propertyName of propertyNames) {
const at = `${modelName}.${propertyName}`
if (!IDENTIFIER.test(propertyName)) problems.push(`property ${at} isn't an identifier`)
if (RESERVED.includes(propertyName)) problems.push(`property ${at} is reserved`)
if (/Ids?$/.test(propertyName)) problems.push(`property ${at} ends in Id or Ids`)
const def = properties[propertyName]
const relationList = def.type === 'List' && def.refValueType === 'Relation'
if (!relationList && ABI_WORDS.includes(easName(propertyName))) {
problems.push(`property ${at} publishes as a Solidity word`)
}
}
}
return problems
}Every name that passes publishes under a predictable EAS name that no other model or property of the same model shares.
Choose types
Property types lists every type; any other name, such as Integer, fails the schema load. The ones that need a decision up front:
Textholds 255 characters by default. Longer values fail validation, when the item is created and when a value is assigned. For article bodies useHtml, which has no default limit. For text that's a bit longer, such as a summary, raise the limit with"validation": { "maxLength": 1000 }. The limit applies to each element of aListofTexttoo.- Every value reads back as a string, apart from a
List. Plan to convertNumberandBooleanvalues when you read them (helpers). Jsonobjects must be stringified before you store them, or they're saved as'[object Object]'.Numberpublishes as its decimal string, so negative numbers, decimals and values above 255 all publish.- Relations point from the "many" side. A
Posthas anauthor; anAuthorhas no list of posts. See Modeling relations.
Add validation that's enforced
Validation rules live under validation on a property in seed.schema.json. They're checked when an item is created and whenever a value is saved, and they combine (Validation):
"title": { "type": "Text", "required": true, "validation": { "minLength": 1, "maxLength": 120 } },
"summary": { "type": "Text", "validation": { "maxLength": 1000 } },
"slug": { "type": "Text", "validation": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } },
"status": { "type": "Text", "validation": { "enum": ["draft", "review", "live"] } },
"body": { "type": "Html", "validation": { "maxLength": 200000 } }pattern, minLength and maxLength apply to Text and Html (and a List of either) only. On any other type they're an error with code rule_not_applicable, and the schema fails to load before anything is stored. custom isn't supported: the SDK logs a warning when it first stores the file, and values save as if the rule weren't there. Rules on properties created in code (Model.create, ModelProperty.create) are stored with the property in the local database, but other devices don't get them until they're in a schema file, so keep rules in seed.schema.json.
required: true isn't checked locally, by create() or by a save. Publishing checks it for Relation, Image, File, Html and Json properties only.
Change the schema: edit, raise the version, restart
When seed.schema.json is passed as config.schema, the client applies it at start whenever it changed since it was last applied, and skips it otherwise. To change the schema:
- Edit
seed.schema.json. - Raise
versionby one and updatemetadata.updatedAt. - Reload the page, or restart your Node process.
For example, to give Post a URL slug and add a Tag model to the tutorial's blog schema, go from version 2 to version 3:
{
"$schema": "https://seedprotocol.io/schemas/data-model/v1",
"version": 3,
"metadata": {
"name": "blog",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-10-20T00:00:00.000Z"
},
"models": {
"Post": {
"description": "A blog post",
"properties": {
"title": { "type": "Text", "required": true },
"slug": { "type": "Text", "validation": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } },
"summary": { "type": "Text" },
"body": { "type": "Html" },
"published": { "type": "Boolean" },
"publishedAt": { "type": "Date" },
"wordCount": { "type": "Number" },
"tags": { "type": "List", "refValueType": "Text" },
"author": { "type": "Relation", "model": "Author", "required": true }
}
},
"Author": {
"description": "Someone who writes posts",
"properties": {
"name": { "type": "Text", "required": true },
"bio": { "type": "Text" }
}
},
"Tag": {
"description": "A topic posts can be filed under",
"properties": {
"name": { "type": "Text", "required": true }
}
}
},
"enums": {},
"migrations": []
}On the next start the SDK adds Tag and Post.slug to the local database, keeps everything that was already there, and writes a copy of the new version to the files directory ({id}_blog_v3.json).
Always raise the version with an edit. The SDK applies an edit at the same version too, but it replaces that version's copy in the files directory, so the previous copy is gone, and it logs [seed] schema <name> v<n>: content changed but id <id> and version did not; replacing the stored schema file. Bump "version" when the schema changes.
Adding models and properties is safe for items you already have. Removing or renaming something in the file doesn't delete it from the database; see Unsafe changes before you do either.
Schema.saveNewVersion() saves schema edits made in code as a new version file, but it doesn't change seed.schema.json. If the app keeps passing the older file, the next start reports the older version and every later saveNewVersion() throws ConflictError. Edit the file instead; see saveNewVersion if you need it.
Fill in a new property
A new property starts empty on existing items. To give them a value, run a one-off backfill once the client is ready after the restart. Await save() on each property to make sure the value was written:
import { Item } from '@seedprotocol/sdk'
import type { IItem } from '@seedprotocol/sdk'
type Post = IItem<any> & { title?: string; slug?: string }
function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export async function backfillSlugs() {
const posts = (await Item.all('Post', false, { waitForReady: true })) as Post[]
for (const post of posts) {
if (post.slug || !post.title) continue
const slug = slugify(post.title)
if (!slug) continue
post.slug = slug
await post.properties.find((p) => p.propertyName === 'slug')?.save()
}
}Item.all returns local drafts by default. Published items need includeEas: true, and changing them only takes effect for other readers once they're published again.
Gotchas
- A malformed file fails init. If the file can't be applied (for example a
Listwith norefValueType),client.init()rejects withCould not load config.schema…: <reason>, andSeedClientGatereports it throughonInitError. If you ignore the rejection,Item.createfor that schema's models throwsUnknownModelError, so treat it as fatal. See Your first schema. - New rules reject new items.
create()validates too, so a rule you add rejects new items that break it, and imports of older data may need{ validate: false }.
Related
- Your first schema: the file format, step by step
- Schemas, Models, Items, Properties: how the pieces fit together
- Schema, Model and ModelProperty: reference
- Change a schema after you've shipped: renames, type changes and what each change does to existing data
- Modeling relations
Conflict detection
What the SDK does when the same data is changed in two places: the optimistic check on schema saves and where it stops, last write wins for items on one device and between published edits, and the conflict it records when a sync meets an unpublished edit.
Model relations between items
Define Relation and List-of-Relation properties, set and read them, load related items, find an item's children, and know what publishing does with the items you point at.