Create the blog app
Start the tutorial by creating a Vite + React app with Seed Protocol that writes and reads a post, entirely in the browser.
This tutorial builds a small blog app from an empty project. This page sets up the app and creates and lists a post, using the SQLite database the SDK runs in the browser. Later pages add more models, relations, reactive forms and, at the end, publishing. Until then you don't need a wallet, and nothing is signed or uploaded.
To add Seed to an app you already have, follow Add Seed to your app instead. It explains each setup step below in more detail and has the pnpm and bun commands.
You'll need Node.js 20.19 or a later 20.x, or 22.12 through 24. The commands use npm.
1. Create a project
Start from Vite's React + TypeScript template:
npm create vite@latest my-seed-app -- --template react-ts
cd my-seed-app2. Install Seed Protocol
Install the template's own dependencies, then the SDK, the React bindings and the Vite plugin:
npm install
npm install @seedprotocol/sdk @seedprotocol/react
npm install --save-dev @seedprotocol/vite3. Add the Vite plugin
Replace vite.config.ts with:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { seedVitePlugin } from '@seedprotocol/vite'
export default defineConfig({
plugins: [react(), ...seedVitePlugin()],
})The plugin sets the cross-origin isolation headers the local database requires on the dev server, and the build settings its worker needs. It returns an array, so spread it into plugins.
4. Define a model
An item is an instance of a model, the shape of a piece of content. This page uses one model, a Post with a title. Put it in a schema file at the project root, next to package.json:
{
"$schema": "https://seedprotocol.io/schemas/data-model/v1",
"version": 1,
"metadata": {
"name": "blog",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
"models": {
"Post": {
"properties": {
"title": { "type": "Text" }
}
}
},
"enums": {},
"migrations": []
}5. Start the client
Create src/seed.ts with the client config. The SDK doesn't find the schema file on its own, so the config imports it and passes it in. filesDir is where the SDK keeps its data in the browser's private file system; .seed is also the default, so you can leave it out:
import type { SeedConfig } from '@seedprotocol/sdk'
import schema from '../seed.schema.json'
export const config: SeedConfig = {
filesDir: '.seed',
schema,
}Then replace src/main.tsx to start the client and wrap your app:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SeedProvider, SeedClientGate } from '@seedprotocol/react'
import { config } from './seed'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<SeedProvider>
<SeedClientGate initConfig={{ config }}>
<App />
</SeedClientGate>
</SeedProvider>
</StrictMode>,
)The SDK loads its local database asynchronously. SeedClientGate initializes the client and shows a loading screen until it's ready, so nothing inside it reads or writes too early. SeedProvider supplies the query cache the list hooks use.
6. Write and list posts
useCreateItem gives you a createItem function. Pass it the model name and the property values, and the SDK saves a new item to the local database. useItems returns the items of a model and re-renders when items are added or removed. To show a property value that stays current, each row uses useItemProperty, which subscribes to one property of one item.
Create src/Posts.tsx:
import { useState } from 'react'
import { useCreateItem, useItems, useItemProperty } from '@seedprotocol/react'
function PostRow({ seedLocalId }: { seedLocalId: string }) {
const { property } = useItemProperty({ seedLocalId, propertyName: 'title' })
return <li>{property?.value ?? 'Untitled'}</li>
}
function PostList() {
const { items, isLoading } = useItems({ modelName: 'Post' })
if (isLoading) return <p>Loading posts…</p>
if (items.length === 0) return <p>No posts yet.</p>
return (
<ul>
{items.map((item) => (
<PostRow key={item.seedLocalId} seedLocalId={item.seedLocalId} />
))}
</ul>
)
}
export function Posts() {
const { createItem, isLoading, error } = useCreateItem()
const [title, setTitle] = useState('')
const handleCreate = async () => {
if (!title.trim()) return
await createItem('Post', { title })
setTitle('')
}
return (
<div>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
/>
<button onClick={handleCreate} disabled={isLoading}>
{isLoading ? 'Saving…' : 'Add post'}
</button>
{error && <p>Couldn't save: {error.message}</p>}
<PostList />
</div>
)
}createItem doesn't throw. If a value is invalid, such as a title over 255 characters, it saves nothing, returns undefined and puts the error in error. A call made while an earlier one is still running creates nothing: it returns the earlier call's promise, which resolves to that call's item whatever values the second call passed. The sample disables the button while isLoading is true, so a click during a save does nothing.
Finally, replace src/App.tsx so it renders Posts:
import { Posts } from './Posts'
export default function App() {
return <Posts />
}7. Run it
npm run devOpen the app, type a title, and click Add post. The post appears in the list straight away, because useItems runs a live query on the local database. Refresh the page and it's still there.
If the app stays on "Loading...", check the browser console and the troubleshooting list.
Next
Your first schema expands the schema into a blog with posts and authors.
Create and read items
Write an item to the local database from a React component, list a model's items, and show their property values as they change.
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.