Your first schema
Define the shape of your content with a Seed Protocol schema, a versioned JSON file that declares your models and their property types.
A schema is a named, versioned set of models, your app's content types. Each model declares typed properties, and each item you create is one record of a model. Create the blog app used a schema with one model, Post. This page grows it into the blog schema the rest of the tutorial uses. Schemas, Models, Items, Properties shows how the four fit together.
The schema file
The schema lives in seed.schema.json at the project root. The SDK doesn't look for it on its own: src/seed.ts imports it and passes it to the client as schema (step 5 of Create the blog app).
schema also accepts a file path. In the browser, a relative path resolves inside the browser's private file system (OPFS), not your project folder, so import the JSON instead. On Node, a relative path resolves from the current working directory.
Each time the client starts, it reads the schema and, if the schema changed since it was last applied, writes its models and properties into the local SQLite database. It doesn't check the file against the format as a whole. A schema that can't be read or applied makes client.init reject with Could not load config.schema: <reason>, or Could not load config.schema from "<path>": <reason> when you passed a path; SeedClientGate passes that error to onInitError. When two models, or two properties of one model, would publish under the same EAS name (HTMLPage and HtmlPage, for example), it logs a warning and loads the schema anyway. Other mistakes show up later, when a value fails validation on create or save.
The blog schema
Replace seed.schema.json with a schema that has two models and a relation between them. Its version is 2 because it changes the file from page one (Versioning explains why that matters):
{
"$schema": "https://seedprotocol.io/schemas/data-model/v1",
"version": 2,
"metadata": {
"name": "blog",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
"models": {
"Post": {
"description": "A blog post",
"properties": {
"title": { "type": "Text", "required": true },
"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" }
}
}
},
"enums": {},
"migrations": []
}config.schema requires only metadata.name and models, and version defaults to 1. The tutorial keeps all six top-level keys, which match the copy of the schema the SDK writes, and keeps version in particular because you raise it with every edit (Versioning). The SDK derives IDs for the schema, its models and its properties from their names, so you don't write those. The schema file format describes every key.
Reload the app and the client registers both models. The next page, Creating items, creates an author and a post that points to it.
Put relations on the "many" side of a one-to-many relationship. Here, each Post points to its Author; there is no posts list on Author. That way no list on Author has to be kept in sync with the posts. See Modeling relations for why.
Property types
Each property needs a type. The type name is case-insensitive, and string is accepted as an alias for Text. Any other name, such as Integer, makes client.init reject with an error that names the property. There are ten types:
| Type | Stores | Example use |
|---|---|---|
Text | A string, up to 255 characters by default | Titles, slugs, short summaries |
Number | A number | Counts, ratings |
Boolean | true or false | Published flags, feature toggles |
Date | A date string. Use ISO 8601; the SDK accepts anything Date.parse can read | publishedAt |
Json | Any JSON value except an array at the top level | Structured settings, extra metadata |
Html | HTML content of any length, kept in its own file | Article bodies, rich descriptions |
Image | An image file | Cover photos, avatars |
File | Any file | Attachments, downloads |
Relation | A reference to one other item | author → Author |
List | An array of values of one element type | Tags, co-authors |
Use Html for long-form content, or raise the Text limit with a validation rule (see below). Html, Image and File values are stored as separate files and uploaded to Arweave when you publish; see File and image properties.
Every type except List holds one value. Saving an array to any other type fails validation, including Json. A List names its element type in refValueType, and the target model in ref when the elements are relations:
"tags": { "type": "List", "refValueType": "Text" },
"authors": { "type": "List", "refValueType": "Relation", "ref": "Author" }A List with ref and no refValueType is treated as a list of relations; one with neither makes client.init reject. A Relation names its target model in model.
Required and validation
required: true marks a property as required for publishing. Locally you can still create and save items without it. Publishing checks required Relation, Image, File, Html and Json properties; the blog schema relies on this for Post.author.
validation holds rules checked when an item is created and whenever a value is saved, such as "validation": { "maxLength": 1000 } to lift the Text limit for one property. Validation lists the rules and the types they apply to; a rule on a type it doesn't apply to, such as maxLength on a Number, makes client.init reject too.
A model or property can also carry a description. The SDK keeps it in the file for people and tooling but doesn't use it.
Schema names and model names
Schema name (metadata.name) identifies the schema in hooks and API calls, for example useModels('blog') or useModel('blog', 'Post').
Model names are the keys under models. They are PascalCase by convention (Post, not post). The model name is what you pass to useCreateItem, useItems, and similar APIs. Model names only need to be unique within a schema. If two schemas loaded in the same app both define Post, APIs that resolve a model by name alone (such as Item.create and Model.getByName) throw an AmbiguousModelError, and useItems({ modelName: 'Post' }) returns both schemas' posts. Pass the schema name too, for example useItems({ modelName: 'Post', schemaName: 'blog' }).
Property names are camelCase by convention (publishedAt, not published_at). They become the field names on items, such as item.title. Some names are reserved for fields the SDK manages on every item, including createdAt, updatedAt, type, publisher, seedLocalId and seedUid. Avoid names ending in Id or Ids too: the SDK stores relation values under those suffixes. A model or property that publishes as a reserved Solidity word, such as a model Event or a property view, stops the schema from loading.
Versioning
The version field tracks schema evolution. When you pass a schema through config.schema, the SDK applies it at start whenever it changed since it was last applied, adding new models and properties to the local database, and skips it otherwise. It also writes its own copy of each version to the files directory, named {id}_{name}_v{version}.json.
For day-to-day development, edit seed.schema.json, raise version, and reload the app.
Raise version with every edit. If the file changes but its version doesn't, the SDK still applies the edit, but it replaces that version's copy in the files directory, so the previous copy is gone, and it logs a warning that asks you to raise version.
Renaming properties without losing values and changing types once you have real data are covered in Change a schema after you've shipped.
Treat published models as contracts. On-chain, a published item is identified by its model name, and each property value by its property name and type. Other Seed-compatible apps read your content from EAS and Arweave by those names and types, so prefer adding new properties over renaming or removing existing ones once data is published.
Scripts and tests can also define schemas in code with Schema.create and Model.create once the client has started. The tutorial uses the file.
Where to go next
- Creating items: create an author and a post that points to it
- Define a schema and add to it: names, types and validation that hold up when you publish, and adding to the schema
- Schema and ModelProperty: every key, type and rule