Add Seed to your app
Install Seed Protocol in an existing Vite + React app, add the Vite plugin, and start the client when the app loads.
These steps add the SDK to a Vite + React app you already have. When they're done, the Seed client starts when your app loads and your components can use the Seed hooks. To start from an empty project instead, follow the tutorial.
The SDK keeps a SQLite database and your files in the browser's private file system (OPFS). That needs a cross-origin isolated page and a few build settings. The @seedprotocol/vite plugin sets them up for the Vite dev and preview servers; in production, your host sends the headers.
Requirements
- Node.js 20.19 or a later 20.x release, or 22.12 through 24. The SDK's
enginesfield is^20.19.0 || >=22.12.0 <=24. - Vite 8 or later.
@seedprotocol/vitedeclaresvite >= 8.0.0as a peer dependency, and@vitejs/plugin-react6 is the matching React plugin. For a Next.js app, see @seedprotocol/next instead. reactandreact-dom18 or 19.- npm, pnpm or bun. These docs don't cover Yarn.
- For your users: Chrome or Edge 102, Firefox 114, or Safari 26 or later. In an older browser,
client.initrejects withUnsupportedBrowserError.
Install the packages
npm install @seedprotocol/sdk @seedprotocol/react
npm install --save-dev @seedprotocol/vite| Package | Why you need it |
|---|---|
@seedprotocol/sdk | The client, the local database, and the Schema, Model and Item classes |
@seedprotocol/react | SeedProvider, SeedClientGate and the React hooks. The hooks aren't exported from the SDK package |
@seedprotocol/vite | The Vite plugin. It's a build-time dependency only |
XState, Drizzle and the browser file system (ZenFS) install with the SDK. react-dom and TanStack Query are peer dependencies of @seedprotocol/react, which npm, pnpm and bun install for you. If your app already uses TanStack Query, see If you already use React Query.
For a Drizzle query of your own, such as one passed to useLiveQuery, import the operators from @seedprotocol/sdk/drizzle (import { eq } from '@seedprotocol/sdk/drizzle'). It re-exports the SDK's copy of drizzle-orm, so you don't install Drizzle yourself. If your app installs drizzle-orm for other reasons, keep it on 0.44: the SDK's range is ^0.44.3, which excludes 0.45.
Keep every @seedprotocol/* package on the same version. @seedprotocol/react declares the exact SDK version as its peer, so npm refuses a mismatched pair with ERESOLVE.
Don't add @zenfs/core or @zenfs/dom to your own dependencies. The app doesn't need them unless it sets the plugin's fsModules option or imports ZenFS itself; then install the exact versions the SDK lists in its dependencies (see node_modules/@seedprotocol/sdk/package.json). Two copies of ZenFS at different versions break the file system: the SDK mounts OPFS in one copy while code reads from the other.
Add the Vite plugin
Add seedVitePlugin() to the plugins array in your Vite config, next to the plugins you already have. It returns an array of plugins, so spread it:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { seedVitePlugin } from '@seedprotocol/vite'
export default defineConfig({
plugins: [react(), ...seedVitePlugin()],
})With no options, the plugin sets cross-origin isolation headers on the vite dev server and vite preview, builds workers as ES modules (the database worker needs it), keeps the dev pre-bundle in order, and de-duplicates React and the Seed packages. Node polyfills and fs aliases are off unless you turn them on. A cross-origin isolation header you already set in server.headers or preview.headers wins over the plugin's. @seedprotocol/vite has the full list and the plugin's options.
Add a schema
The schema lists the models your app stores. Start with one model and add more as you need them. Keep the schema in a JSON file at the project root:
{
"$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": []
}The SDK doesn't look for this file on its own: in the next step you import it and pass it to the client, and Vite bundles it into the app. SeedConfig.schema is typed as SchemaFileInput, which requires only metadata.name and models; the other keys match the copy of the schema the SDK writes, and TypeScript accepts the import without a cast. Your first schema covers the format and the property types.
Write the client config
import type { SeedConfig } from '@seedprotocol/sdk'
import schema from '../seed.schema.json'
export const config: SeedConfig = {
schema,
}schema takes the schema object (or a path to a schema file in OPFS). Each time the client starts, the SDK applies the schema to the local database if it changed since it was last applied, adding and updating models and properties, and skips it otherwise. It's the only option you need. The SDK keeps its database and files in the OPFS directory .seed unless you set filesDir to another one.
Client and configuration lists every SeedConfig option, including the gateway and EAS chain settings you'll want before publishing.
Start the client
Wrap your app in SeedProvider and SeedClientGate where you render it, usually src/main.tsx:
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>,
)SeedProvider supplies the React Query client the Seed hooks use and keeps their caches in step with SDK events. useItems and useItemProperties throw without it, so keep it above every component that calls a Seed hook (which hooks need it). SeedClientGate calls client.init(initConfig) and shows a loading screen until the client is ready. Its children stay mounted but hidden in the meantime, so components inside it can call the Seed hooks straight away. SeedProvider can also start the client itself from a config prop, without a loading screen; see SeedProvider with config.
Because the gate hides its children while the client starts, wrapping your whole app hides all of it for that time. To keep the rest of the app visible, put the gate around only the routes or components that use Seed.
initConfig takes the client's init options, and config is the only required field. Once you know the user's wallet addresses, pass them too, as initConfig={{ config, addresses }}. SeedClientGate covers a custom loading UI and handling a failed start with onInitError.
Run it
npm run devOpen the app. The gate's "Loading..." shows while the client starts, then your app renders. If it renders, Seed is set up. Create and read items picks up from here.
If you already use React Query
Pass your own client to SeedProvider so Seed and the rest of your app share one cache. Build it with Seed's defaults:
import { SeedProvider, createSeedQueryClient } from '@seedprotocol/react'
import App from './App'
const queryClient = createSeedQueryClient()
export function Root() {
return (
<SeedProvider queryClient={queryClient}>
<App />
</SeedProvider>
)
}SeedProvider renders a QueryClientProvider with that client for everything inside it, so where it wraps your whole app it can replace your own QueryClientProvider. To start from your own defaults instead, pass them through mergeSeedQueryDefaults(), where your options win over Seed's:
import { QueryClient } from '@tanstack/react-query'
import { mergeSeedQueryDefaults } from '@seedprotocol/react'
const queryClient = new QueryClient({
defaultOptions: mergeSeedQueryDefaults({ queries: { gcTime: 1000 * 60 * 60 } }),
})Don't swap SeedProvider for a bare QueryClientProvider. The list hooks would run, but nothing would refresh them after saves, syncs or address changes.
What cross-origin isolation changes in an existing app
The SQLite database runs in a worker on top of OPFS, which needs the page to be cross-origin isolated. On a page without the headers, client.init rejects with DatabaseNotPersistentError and SeedClientGate stays on its loading screen. The plugin sets the headers on vite and vite preview only. In production your host has to send Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy on the HTML page; Headers in production has the values and host examples.
The headers apply to every page that loads Seed, so check the features your app already has before you ship:
- Popups to other sites. With
Cross-Origin-Opener-Policy: same-origin, a window your page opens on another origin can't reach back throughwindow.opener. Sign-in or payment flows that return their result that way stop working. - Embedded pages. With
Cross-Origin-Embedder-Policy: credentialless, a cross-origin<iframe>loads only if the embedded page sends aCross-Origin-Embedder-Policyheader of its own. - Cross-origin images, scripts and fetches. Requests made without CORS are sent without cookies, so a resource on another origin that needs the user's cookies won't load as before.
If a feature breaks and you can't change it, keep it on pages that don't load Seed, and send the headers only on the pages that do.
Without React
SeedClientGate calls client.init for you. In code outside React, call it yourself and wait for it before using Item, Model or the other SDK APIs:
import { client } from '@seedprotocol/sdk'
import { config } from './seed'
await client.init({ config })Calls made while init is in progress share the same promise, and once the client is ready a call with the same config resolves straight away. A call with a different config throws; await client.stop() first, and init then starts the client again with the new config. See Client and configuration.
TypeScript
The packages ship their own type definitions, so you don't need any @types packages. If your tsconfig.json rejects import schema from '../seed.schema.json', turn on "resolveJsonModule": true. With verbatimModuleSyntax, import types with import type, as src/seed.ts does.
Troubleshooting
npm error ERESOLVEwithCould not resolve dependency: peer @seedprotocol/sdk@"<version>" from @seedprotocol/react. Your@seedprotocol/*packages are on different versions. Install them all at the same version.- The client never gets past "Loading...". Open the browser console.
SeedClientGatelogs[SeedClientGate] client init failedwith the cause when you haven't passedonInitError. If the cause isDatabaseNotPersistentError, the page isn't cross-origin isolated: check that your ownserver.headersdon't replace the plugin's with other values, that you haven't setisolationHeaders: false, and in production, your host's headers. UnsupportedBrowserError/ "This browser lacks features Seed needs". The browser is older than the ones in Requirements, and retrying won't help. Show the user a message asking them to update it: check forcode === 'BROWSER_UNSUPPORTED'inonInitError, or callcheckBrowserSupport()before init. UnsupportedBrowserError has a sample.FileSystemLockedError/ "The browser file system is in use by another tab or worker". Another tab or worker has the app's storage open. Close the other tabs and retry, or handle it inonInitError.- File reads fail or come back empty, or OPFS gets remounted unexpectedly. You probably have two copies of ZenFS. The @seedprotocol/vite troubleshooting list has the check and the fix.
Buffer is not defined,process is not defined, or a module "has been externalized for browser compatibility". A dependency of your app expects Node. See the @seedprotocol/vite troubleshooting list.
How Seed Protocol works
The architecture in one page: a local-first app on top, public permanent networks underneath, and an account the user controls signing the record of who made what.
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.