# Graft — full documentation
> Graft is an open-source CMS that keeps content as Markdown files in Git, checks every file against a TypeScript schema, and gives your app fully typed reads. AI agents can edit the same content through the Model Context Protocol (MCP), within limits you set.
# What is Graft
> An open-source CMS that keeps your content in Git, types it with your schema, and lets AI agents edit it safely.
*What Graft does, how it works, and where to start reading.*
Most sites need content that someone other than the developer edits: pages,
docs, product listings. The usual answer is a hosted CMS, which moves that
content out of your repository and into someone else's database. You lose diffs,
review, and branches, and your app reads it back through an API with no types.
Graft keeps the content in your repository as Markdown files. It checks every
file against a schema you write in TypeScript, builds an index your app can
query, and gives your components fully typed results. AI agents can edit the
same files through the Model Context Protocol (MCP), the open standard that
lets agents use tools, within limits you set.
> **Beta.**
> These docs describe `1.0.0-beta.x`. The overall design is settled. Details
> still change between betas, and every change is listed in the changelog.
## How it works
```text
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ Markdown in Git │ ───► │ graft compile │ ───► │ Typed reads in app │
│ content/pages/*.mdx │ │ check + build index │ │ Next.js, Astro, React │
└────────────────────────┘ └────────────────────────┘ └────────────────────────┘
▲ │
│ │
└─────────── You (editor, Git, CLI) and agents (MCP) ────────────┘
```
1. **You write content as files.** Markdown, or MDX (Markdown that can include
components), in your project's `content/` folder. You commit, branch, and
review it like code.
2. **`graft compile` checks it and builds an index.** Every file is checked
against your schema, and a bad file stops the compile with a message saying
what to fix. The index is a searchable copy of your content. Your files stay
the source of truth, so the index can be deleted and rebuilt at any time.
3. **Your app reads typed content.** Graft has a package for each major
framework. A read returns the document with types taken from your schema, so
your editor knows every field.
4. **Agents work the same loop.** An agent connected over MCP can draft pages,
search content, and read your schema. It uses the same operations you do, so
the same checks apply.
## Static or Postgres?
The index lives in one of two storage engines. You write and read content the
same way on both.
| Storage engine | Use it for | What you run |
| :--- | :--- | :--- |
| **Static** (what `graft init` sets up) | Blogs, documentation, marketing sites, portfolios | Nothing. The index is a SQLite file built by `graft compile`. |
| **Postgres** | Sites where users write data at runtime, such as form submissions, comments, and orders. Also preview branches. | A Postgres database. |
Start with static. Switching later is one line in `graft.config.ts`, and
[Storage engines](/docs/tiers) explains when to switch and how.
## How these docs are organized
- **Get started** explains what Graft is and walks you through a first project.
- **Frameworks** has one guide per framework, from install to a rendered page.
- **Content** covers the schema, reading, search, assets, and the editing UI.
- **Runtime data** covers data your users write, such as form submissions and
orders, and who may write it. This needs the Postgres engine.
- **Agents & MCP** covers connecting agents and every tool they can call.
- **Deploy & operate** covers hosting, security, previews, and migrations.
- **Reference** lists every command, setting, API, and error code, plus a
[glossary](/docs/glossary) of Graft's terms.
You should be comfortable with TypeScript and with one of the frameworks below.
You do not need to know Zod, MCP, or Postgres before you start. Each one is
introduced where you first need it.
## Where to go next
New to Graft? Read [Getting started](/docs/getting-started) next. It takes
about ten minutes and ends with your content rendered in your app. If you
already know what you are building, jump straight to it:
- [Next.js](/docs/next): Typed reads in Server Components, MDX rendering, and cache revalidation.
- [React in the browser](/docs/react): Hooks that read content over HTTP. No database in the client.
- [Astro](/docs/astro): Read content in page frontmatter and ship zero client JavaScript.
- [SvelteKit](/docs/sveltekit): Read in server load functions and mount Graft on a server route.
- [Securing a deployment](/docs/hardening): Give your app a database role that cannot approve its own destructive changes.
- [Connecting an agent](/docs/connecting-agents): Point Claude Code, Cursor, or any MCP client at your project.
---
# Getting started
> Create a Graft project, compile content, and read it back typed.
*Go from an empty folder to your content on a page in about 10 minutes.*
> **Starting from nothing?**
>
> `npm create graft@latest my-site` makes a whole site in one step: a template,
> a database, Studio and a Next.js app, with example content. This guide adds
> Graft to a project you already have.
>
In this guide you will:
- Set up Graft in a project with `graft init`
- Compile your content, which checks each file and builds the index your app
reads from
- Read a document in your app, with full TypeScript types
## Before you start
You need **Node.js 22.16 or newer**. Graft's static index uses the `node:sqlite`
module that ships with it. You do not need a database or a cloud account.
> **Prompt for a coding agent:**
> Add Graft to this project following
> https://graft.page/docs/getting-started.md. Use the static engine, compile
> the sample content, and render the `home` page.
## Set up your first project
Pick the tab that matches your project. Each one runs the same three steps:
set up, compile, read. The Next.js and Astro paths end with a rendered page.
The plain TypeScript path prints the document in your terminal, with no
framework involved. Other frameworks are listed [after the tabs](#your-framework).
**Next.js:**
### Next.js (App Router)
Goal: render a compiled document in a Server Component.
**Install the Next.js SDK and CLI**
The SDK supports Next.js 15 and 16. Check yours with
`npx next --version`. Releases before 15.5.24 on 15, or 16.3.3 on 16, have
critical security advisories, so upgrade if you can: see
[Check your Next.js version](/docs/next#check-your-nextjs-version). Next.js 14
is not supported, because it will not get fixes for them. These steps use the App Router; the [Next.js guide](/docs/next) covers
the Pages Router.
```sh
npm i @usegraft/sdk-next @usegraft/db
npm i -D @usegraft/cli
```
**Initialize Graft**
```sh
npx @usegraft/cli@latest init
```
This scaffolds `graft.config.ts`, creates sample content in `content/pages/home.mdx`,
and sets up the static index.
**Compile your content**
```sh
npx graft compile
```
This validates your markdown files against the schema and writes an embedded
SQLite index to `.graft/index.db`. The index is derived state: your files in
Git stay authoritative, and recompiling rebuilds it at any time.
**Create the Graft client**
The client is the object your pages read through. Create it once, in its own
module, so every page shares it:
`lib/graft.ts`:
```ts
import { openStaticIndex } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-next";
import { collections } from "@/graft.config";
export const graft = createGraft({
index: await openStaticIndex(".graft/index.db"),
collections,
});
```
It lives outside `app/` on purpose. A Next.js page file may only export the
page and its route options, so a client exported from `app/page.tsx` fails the
build's type check.
**Read content in a Server Component**
Replace `app/page.tsx` to read the sample page and render it:
`app/page.tsx`:
```tsx
import { MdxBody } from "@usegraft/sdk-next";
import { notFound } from "next/navigation";
import { graft } from "@/lib/graft";
export default async function Page() {
const home = await graft.getContent("pages", "home");
if (!home) notFound();
return (
{home.data.title}
{home.data.tagline}
);
}
```
`home.data` is typed from your schema: hover `title` in your editor and you
will see `string`. `MdxBody` renders the Markdown body on the server.
Start your dev server and open `http://localhost:3000`:
```sh
npm run dev
```
You should see the heading **Hello, Graft**, its tagline, and the body text
from `content/pages/home.mdx`. Edit that file, run `npx graft compile` again,
and refresh to see the change.
**Plain TypeScript:**
### Plain TypeScript (No Framework)
Goal: read a compiled document from a script, with no framework involved.
**Scaffold a project folder**
```sh
mkdir my-graft-site && cd my-graft-site
npm init -y
npm pkg set type=module
npm i @usegraft/db @usegraft/sdk-core
npm i -D @usegraft/cli tsx typescript @types/node
npx @usegraft/cli@latest init
```
`type=module` makes the project an ES module, so the script in the last step
can use `await` at the top level.
**Compile content**
```sh
npx graft compile
```
This compiles `content/pages/home.mdx` into `.graft/index.db`.
**Read and verify output**
Create `read.ts`:
`read.ts`:
```ts
import { openStaticIndex } from "@usegraft/db";
import { createClient } from "@usegraft/sdk-core";
import { collections } from "./graft.config";
const index = await openStaticIndex(".graft/index.db");
const graft = createClient({ index, collections });
const home = await graft.getDocument("pages", "home");
console.log("Title:", home?.data.title);
console.log("Tagline:", home?.data.tagline);
```
Without a framework you use `@usegraft/sdk-core` directly, whose read method is
`getDocument`. The framework packages wrap the same call and name it
`getContent`. Both return the same typed document.
Run the script:
```sh
npx tsx read.ts
```
You will see the values from `content/pages/home.mdx`:
```text
Title: Hello, Graft
Tagline: Content lives in git; the index is a file.
```
**Astro:**
### In your Astro project
Goal: render a compiled document in a zero-JavaScript Astro page.
**Install the Astro SDK and CLI**
```sh
npm i @usegraft/sdk-astro @usegraft/db
npm i -D @usegraft/cli
```
**Initialize Graft**
```sh
npx @usegraft/cli@latest init
```
**Compile your content**
```sh
npx graft compile
```
**Read content in an Astro page**
In `src/pages/index.astro`:
```astro
---
import { openStaticIndex } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-astro";
import { collections } from "../../graft.config";
const graft = createGraft({
index: await openStaticIndex(".graft/index.db"),
collections,
});
const home = await graft.getContent("pages", "home");
---
{home?.data.title}
{home?.data.tagline}
```
### Your framework
Every framework reads the same index with the same typed calls. Pick your guide:
| Framework | Package | Guide |
| :--- | :--- | :--- |
| Next.js (App Router or Pages Router) | `@usegraft/sdk-next` | [Next.js](/docs/next) |
| Astro | `@usegraft/sdk-astro` | [Astro](/docs/astro) |
| SvelteKit | `@usegraft/sdk-sveltekit` | [SvelteKit](/docs/sveltekit) |
| TanStack Start | `@usegraft/sdk-tanstack-start` | [TanStack Start](/docs/tanstack-start) |
| React Router (framework mode) | `@usegraft/sdk-react-router` | [React Router](/docs/react-router) |
| React in the browser | `@usegraft/sdk-react` | [React](/docs/react) |
| Plain Node or TypeScript | `@usegraft/sdk-core` | [SDK reference](/docs/sdk-reference) |
## Add your own content
Each document is a file at `content//.mdx`:
- A **collection** is a named group of documents, like `pages`. Its fields are
declared in `graft.config.ts`.
- The **slug** is the document's name inside the collection. `content/pages/about.mdx`
has the slug `about`.
- **Frontmatter** is the block between the `---` lines at the top. Its fields
must match the collection's schema, or the compile stops and says which field
is wrong.
- The rest of the file is the body, in Markdown or MDX (Markdown that can
include components).
Create `content/pages/about.mdx`:
`content/pages/about.mdx`:
```mdx
---
title: About us
tagline: We build with code and AI agents.
---
Welcome! This page lives in Git, next to your code.
```
Then compile again, or leave `graft dev` running so it recompiles whenever a
file changes:
```sh
npx graft compile # compile once
npx graft dev # recompile on every change
```
Read it the same way you read `home`: `graft.getContent("pages", "about")`.
## When you need Postgres
The project you just made uses the static engine, which needs nothing running.
Move to Postgres when users of your site write data at runtime, such as
comments, orders, or form submissions. Git cannot rebuild that data, so it
needs a database. Preview branches need Postgres too.
Switching is one line in `graft.config.ts` (`export const index = "postgres"`)
plus a database URL. [Storage engines](/docs/tiers) walks you through it.
## If something goes wrong
These are the errors people hit most often while getting started:
| Error code | What it means | What to do |
| :--- | :--- | :--- |
| `STATIC_INDEX_NOT_FOUND` | Your app read the index before it was compiled | Run `npx graft compile`, then start your app |
| `STATIC_INDEX_UNSUPPORTED` | Your Node.js version is older than 22.16 | Upgrade Node.js |
| `SCHEMA_VALIDATION_FAILED` | A file's frontmatter does not match its schema | The message names the file and field. Fix it and compile again |
| `NEEDS_DATABASE` | Your config uses a feature that needs Postgres | See [When you need Postgres](#when-you-need-postgres) |
Every error also prints a `fix` line with the exact next step. The
[Error reference](/docs/errors) explains all of them.
## Next steps
You have content in Git, a compiled index, and a typed read. From here:
- [How Graft works](/docs/the-model): What compiling does, and why your files stay the source of truth.
- [Schema](/docs/schema): Describe your own collections and fields.
- [Your framework guide](/docs/next): Rendering MDX, caching, and route handlers, starting with Next.js.
- [Connecting an agent](/docs/connecting-agents): Let Claude Code, Cursor, or any MCP client edit your content.
---
# How Graft works
> Where your content lives, what compiling does, and the one schema that checks everything.
*Four ideas that explain the rest of the docs.*
After [Getting started](/docs/getting-started) you have a project that compiles
and a page that reads content. This page explains what happened, so the rest of
the docs make sense. It introduces four ideas, one at a time:
1. Your files are the source of truth.
2. Content and user data are kept apart.
3. One schema checks everything.
4. Errors tell you what to do next.
## Your files are the source of truth
You write content as files in Git. Your app does not read those files directly,
because searching and filtering hundreds of files on every request would be
slow. Instead, `graft compile` reads them, checks each one, and builds an
**index**: a copy of your content organized for fast queries.
The index is always a copy. If it is lost, deleted, or out of date, run
`graft compile` and it is rebuilt from your files. This is why Graft calls the
index **derived**: everything in it comes from Git.
> **The rule.**
> If the index and your files ever disagree, your files are right. Recompile.
Compiling only changes what changed. Graft compares each file with what the
index already holds and updates just the documents that differ, so unchanged
documents keep their timestamps. The list of documents a compile added,
changed, or removed is called a **ChangeSet**. Framework packages use it to
refresh only the cached pages that changed.
## Content and user data are kept apart
Some data cannot live in files. When a visitor submits a form, places an order,
or leaves a comment, that data is created at runtime, by your users, and there
is no file in Git to rebuild it from. Graft calls this **operational data**.
The two kinds are stored and written differently:
| | Content | Operational data |
| --- | --- | --- |
| Examples | pages, docs, products | form submissions, orders, comments |
| Source of truth | files in Git | rows in Postgres |
| Who writes it | you and agents, by editing files | your users, through typed functions |
| How you declare it | a normal collection | a collection with `authority: "db-authoritative"` |
Operational data needs the [Postgres engine](/docs/tiers), because that is
where its rows live. If a collection is declared one way and written the other,
Graft stops with `AUTHORITY_MISMATCH` and tells you which collection is wrong.
[Operational data](/docs/operational-data) covers it in full.
## One schema checks everything
You describe each collection once, in `graft.config.ts`. That one description
is used everywhere data crosses a boundary:
- `graft compile` checks every file's frontmatter against it.
- Typed functions check their inputs against the `input` schema each one
declares. That can reuse a collection's fields (`input: submissions.fields`)
or describe something else entirely, so a collection's rules apply to a
function only when its input uses them.
- Reads check documents on the way out.
The schema is built on [Zod](https://zod.dev), a TypeScript validation
library, so you get its types for free. There is no code generation step:
`getContent("pages", "home")` is typed by the same object that checked the
file. [Schema](/docs/schema) shows how to write one.
## Errors tell you what to do next
Every Graft error has a `code`, a `message` saying what happened, and a `fix`
naming the next step. The `fix` is written so that both you and an agent can
act on it without searching the docs:
```json
{
"error": "INDEX_OWNERSHIP",
"message": "Refusing to project: this would remove every document in collection(s) \"products\" …",
"fix": "Each Graft project needs its own database (or branch): point DATABASE_URL at …"
}
```
The [Error reference](/docs/errors) lists every code.
## One database per project
On the Postgres engine, compiling makes the index match your current schema
exactly. That includes removing collections your schema no longer declares. If
two projects pointed at the same database, each compile would delete the other
project's content.
Graft prevents this. If a compile would remove collections your schema does not
know about, it stops with `INDEX_OWNERSHIP` and writes nothing. Give each
project its own database, or its own [branch](/docs/branching-previews) of one.
If you renamed or deleted a collection on purpose, run the compile with
`--prune-unknown` to allow the removal.
## Next steps
- [Storage engines](/docs/tiers) helps you choose between static and Postgres.
- [Schema](/docs/schema) shows how to describe your collections.
- [Reading content](/docs/reading-content) covers reads in every framework.
---
# Set up a site for someone
> For a coding agent helping a person who doesn't code: what to ask, what to run, and how to explain each step.
*Written for coding agents. A person asked you for a website they can edit themselves; this is how to give them one.*
You are setting up a site for someone who may never open a terminal. They
should end up with a site that runs, an editor they can use in a browser, and
an idea of what happens next. Keep every message short and free of jargon.
When you must use a term, explain it once in a few words.
## 1. Ask three questions
Ask in plain words, all at once, and accept "I don't know" as an answer.
| Question | Their answer | Flag |
| --- | --- | --- |
| What is the site for? | A blog, documentation, a shop or menu, a portfolio, or something else | `--template blog`, `docs`, `shop`, `portfolio` or `blank` |
| Who changes the content, and how? | "Me, in a browser" (the usual answer), or "developers, in code" | `--edit online` or `--edit code` |
| Do you already have a database? | Almost always "no" or "I don't know" | `--database new`, or `--database ` |
When in doubt, choose `blog`, `online` and `new`. Editing online keeps the
content in a database, and a change goes live as soon as it is published,
with no rebuild.
## 2. Make the site
Run one command with their answers. `--yes` takes the defaults for anything
you didn't ask.
`Terminal`:
```sh
npm create graft@latest bakery -- --template shop --edit online --database new --yes
```
It writes the site, makes a free database, installs everything, loads example
content and prints what to run next. The folder gets an `AGENTS.md` that tells
the next agent how this site works.
Then tell the person what happened, in about this many words:
> Your site is set up with example content. I also made a free database for
> it. It's deleted after three days unless you keep it, which takes one sign-up
> and costs nothing. I'll walk you through that next.
## 3. Keep the free database
The database `--database new` makes is free and needs no account, but it is
deleted after 72 hours unless someone claims it, and until then it holds at
most 100 MB. Claiming it moves it into a free account the person owns.
`Terminal`:
```sh
npx graft db claim
```
It prints a link. Give the link to the person and ask them to open it and sign
up or sign in. The command waits until they have. Claiming changes the
database's password, so it then asks for the new connection string: the person
copies it from their database dashboard (Connect), and you paste it in. Never
ask them to paste a password into a chat you don't control; if they must, tell
them to change it afterwards.
## 4. Show them the site and the editor
`Terminal`:
```sh
npm run dev # the site, at http://localhost:3000
npm run studio # Studio, the editor, at http://localhost:4983
```
Studio is where they will work. On their own computer it needs no sign-in.
Show them one thing: open a page, change a word, press Publish, and reload the
site.
## 5. Check where things stand
Run `npx graft doctor` (or call the MCP tool `setup_status` on a local
`graft mcp`) whenever you are unsure. It checks the database, its schema, the content, the index and Studio
sign-in, and ends with the one thing to do next. Do that thing, then run it
again.
## 6. Put it online
The site needs a host that runs Node.js, with two settings copied from `.env`:
| Setting | What it is |
| --- | --- |
| `DATABASE_URL` | Where the content lives |
| `GRAFT_STUDIO_SECRET` | What keeps people signed in to Studio |
Studio runs with `graft serve --studio`, wherever the site's settings are. The
first person to open `/studio` online creates the owner account. On a server
anyone can reach, it asks for a setup code first, so nobody else can claim it:
`graft serve` prints the code when it starts, and `npx graft studio setup-code`
prints it from the settings. The owner then invites everyone else from
**Settings, People**. See [Host Studio for your team](/docs/studio-hosting).
## Words to use
| Instead of | Say |
| --- | --- |
| Repository, commit, push | "The site's files", "save a version" |
| Database, Postgres | "Where the content is stored" |
| Environment variable | "A setting" |
| Deploy | "Put it online" |
| Migration | Don't mention it: `graft create` and `graft doctor` handle it |
## Rules
- Every Graft error has a `fix`. Do what it says before trying anything else.
- Never commit `.env`, and never paste its contents anywhere public.
- Ask before you publish anything on the person's behalf. A publish is live
immediately.
---
# Storage engines
> Where your content index lives: a SQLite file with nothing to run, or Postgres when users write data at runtime.
*Pick static or Postgres, and know when to switch.*
When you compile, Graft builds an **index**: a searchable copy of your content
that your app reads from. The storage engine decides where that index lives.
It does not change how you write content, how you read it, or what your schema
looks like.
There are two engines:
- **Static.** The index is a single SQLite file, `.graft/index.db`, built by
`graft compile`. You run no database and no services.
- **Postgres.** The index lives in a Postgres database. You need this engine
when your users write data at runtime, such as form submissions, comments,
or orders, because that data cannot be rebuilt from files in Git.
Start static. Move to Postgres the day you need one of the features in
[What only Postgres can do](#what-only-postgres-can-do).
## Which engine am I on?
Look for the `index` export in `graft.config.ts`:
`graft.config.ts`:
```ts
export const index = "static"; // or "postgres"
```
`graft init` writes `"static"` for you. If a config has no `index` export at
all, Graft uses Postgres, so older projects keep working. Set it explicitly and
you never have to remember that rule.
## What works on both
> Works on both storage engines, static and Postgres.
Everything you need to write, compile, and read content:
- `graft init`, `graft compile`, and `graft dev`
- Typed reads through every framework package
- Full-text search, with ranking and highlighted snippets
- Agents over MCP with `graft mcp`, including document resources and prompts
- Uploading assets with `graft asset put`, once storage credentials are set
- Editing content in Studio with `graft studio`: each save updates
`.graft/index.db` ([Studio](/docs/studio#without-a-database))
## What only Postgres can do
> Needs the Postgres storage engine.
These features store data that Git cannot rebuild, so they need a database:
- **Runtime data**: collections your users write to, declared with
`authority: "db-authoritative"` ([Operational data](/docs/operational-data))
- **Typed functions** that write that data, served at `POST /api/fn/`
([Functions & access](/docs/functions-and-access))
- **The audit log, rate limits, and human approvals** for agent actions
([Audit & approvals](/docs/audit-and-approvals))
- **Preview branches** and `graft merge` ([Branching & previews](/docs/branching-previews))
- **Studio's team features**: accounts and sign-in for a hosted Studio,
approvals, the compile history, the media library, agent tokens, and
`graft serve` ([Studio](/docs/studio), [Host Studio for your team](/docs/studio-hosting))
If a static project declares one of these, Graft refuses to load the config and
tells you which declaration needs Postgres:
```text
graft.config.ts uses the static index, but declares typed function(s)
"submitContact" — those live in Postgres, not in the compiled artifact.
```
That error is `NEEDS_DATABASE`. Its `fix` lists the steps in
[Moving to Postgres](#moving-to-postgres).
## Working with the static index file
`graft compile` writes `.graft/index.db`. A few things to know about it:
- **It is generated, so keep it out of Git.** `graft init` adds it to
`.gitignore`. Rebuild it as part of your build command:
`graft compile && next build`.
- **A failed compile never leaves a half-written file.** Each compile builds a
new file and swaps it in only when it is complete.
- **It works on a read-only filesystem.** Your app opens it read-only.
- **It needs Node 22.16 or newer**, which ships `node:sqlite`. There is no
native dependency to install.
- **Each Git checkout has its own file.** That is how branches work on the
static engine: a different checkout compiles a different index. The SDK's
`branch` option is accepted and has no effect here.
Your app opens the file and reads from it:
`lib/graft.ts` (static engine):
```ts
import { openStaticIndex } from "@usegraft/db";
import { createClient } from "@usegraft/sdk-core";
import { collections } from "../graft.config";
const index = await openStaticIndex(".graft/index.db");
export const graft = createClient({ index, collections });
```
If your app reads before the first compile, you get `STATIC_INDEX_NOT_FOUND`.
If your Node version is too old, you get `STATIC_INDEX_UNSUPPORTED`.
## Agents on the static engine
Agents work on both engines. `graft mcp` serves a static project, an agent's
`write_content` call checks the file and recompiles it the same way, and
`search_content` searches the static index.
Tools that need Postgres stay listed, so an agent can see they exist. Calling
one returns `NEEDS_DATABASE` and the steps to upgrade. Two tools refuse on
purpose:
| Tool | Why it refuses on the static engine |
| --- | --- |
| `delete_content` | Deletes wait for a human approval, and approvals are stored in Postgres. Allowing the delete without that check would skip the approval. On the static engine, delete the file and recompile. Git history is your undo. |
| `list_compilations` | The static file keeps only the last 50 compile runs. Returning a partial history under the same tool name could mislead an agent, so the tool reports that it needs Postgres instead. |
## Moving to Postgres
Four steps, in this order.
**Point at a database**
Set `DATABASE_URL` in a `.env` file in your project, or in any parent folder:
```sh
DATABASE_URL=postgres://…
```
**Switch the index**
`graft.config.ts`:
```ts
export const index = "postgres";
```
**Create Graft's tables**
```sh
graft db migrate
```
This creates the tables Graft needs. The SQL ships inside `@usegraft/db`, so
there is nothing else to install. It is safe to run again: it only adds what is
missing. Pass `--dry-run` to see what it would create first.
**Compile again**
```sh
graft compile
```
Then give your client the database instead of the file. With a framework
package, pass `db` where you passed `index` (Next.js shown):
`lib/graft.ts` (Postgres engine):
```ts
import { createDb } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-next";
import { collections } from "@/graft.config";
export const graft = createGraft({
db: createDb(process.env.DATABASE_URL!).db,
collections,
});
```
Without a framework, use `createDbClient` from `@usegraft/sdk-core/db` in the
same way.
## Moving back to static
Set `index = "static"` again. The config refuses to load while it still
declares typed functions or db-authoritative collections, so remove those
first. If one came from a `graft add` primitive, delete it from `graft/`.
Switching back does not delete anything from Postgres. Rows your users wrote
stay in the database.
## One database per project
On the Postgres engine, each project needs its own database, or its own branch
of one. Compiling makes the index match your current schema exactly, so two
projects sharing a database would each delete the other's content. Graft stops
the compile with `INDEX_OWNERSHIP` before that can happen. See
[How Graft works](/docs/the-model#one-database-per-project).
The static engine cannot hit this, because each project has its own file.
## Next steps
- [Getting started](/docs/getting-started) runs the static setup end to end.
- [Operational data](/docs/operational-data) shows what to build once you move
to Postgres.
- [Configuration](/docs/configuration) lists every environment variable.
- [Deploy](/docs/deploy) covers hosting either engine.
---
# Next.js
> Read typed content in Next.js 15 or 16, with the App Router or the Pages Router, render MDX, and refresh only the pages that changed.
*Render Graft content in a Next.js project, from install to cache refresh.*
This guide adds Graft to a Next.js project. By the end, your pages read typed
content, render its Markdown with your own components, and refresh only the
pages whose content changed. The steps use the App Router. If your project
uses the Pages Router, follow them up to the client, then
[use the Pages Router](#use-the-pages-router).
**Before you start**, you need a Next.js 15 or 16 project and a
`graft.config.ts` with compiled content. If you have not got those yet, run
through [Getting started](/docs/getting-started) first. It takes about ten
minutes. Still on Next.js 14? Read
[Run Graft on Next.js 14](#run-graft-on-nextjs-14) first.
What `@usegraft/sdk-next` gives you:
- **Typed reads.** Every result is typed from your schema, in Server
Components, route handlers, `getStaticProps` and `getServerSideProps`.
- **One read per document per request.** If a layout and a page read the same
document, Graft reads it once, using `React.cache`.
- **Markdown rendering on the server.** `` renders a document's body
without sending an MDX parser to the browser.
- **Targeted cache refresh.** `revalidateContent` refreshes only the pages whose
content changed.
> **Prompt for a coding agent:**
> Add Graft to this Next.js app following https://graft.page/docs/next.md. Put
> the client in lib/graft.ts and render the pages collection at
> app/[slug]/page.tsx.
## Check your Next.js version
Graft supports Next.js 15 and 16, with the App Router or the Pages Router.
What differs between them:
| | Next.js 15 | Next.js 16 |
| --- | --- | --- |
| App Router and Pages Router | Yes | Yes |
| Cached reads you can refresh | `unstable_cache` | `'use cache'` and `cacheTag` |
| `revalidateContent` | Yes | Yes |
| `updateContent` | No | Yes |
Graft does not refuse an older 15 or 16 release, but some are not safe to run.
15.5.24 and 16.3.3 are the first releases with fixes for the critical Next.js
advisories published in 2026. On a release without those fixes, `withGraft`
prints a warning when Next.js loads your config that names the release to
upgrade to:
```text
graft: Next.js 15.5.23 has known critical security advisories. Upgrade to 15.5.24 or later.
```
## Install
**npm:**
```sh
npm i @usegraft/sdk-next @usegraft/db
```
**pnpm:**
```sh
pnpm add @usegraft/sdk-next @usegraft/db
```
**bun:**
```sh
bun add @usegraft/sdk-next @usegraft/db
```
## Wrap your Next.js config
Wrap your config with `withGraft`. It keeps `@usegraft/registry` out of the
server bundle, because that package reads files from disk at runtime, and it
writes the setting under the name your Next.js version reads. Plain content
reads work without it, but the
[MCP route](#serve-functions-and-mcp-from-your-app) needs it, so add it now:
`next.config.ts`:
```ts
import { withGraft } from "@usegraft/sdk-next/config";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// your existing Next.js options
};
export default withGraft(nextConfig);
```
## Create the Graft client
Create the client once, in a file only the server imports. Pick the version for
your [storage engine](/docs/tiers).
**Static:**
`lib/graft.ts`:
```ts
import { openStaticIndex } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-next";
import { collections } from "@/graft.config";
export const graft = createGraft({
index: await openStaticIndex(".graft/index.db"),
collections,
});
```
**Postgres:**
`lib/graft.ts`:
```ts
import { createDb } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-next";
import { collections } from "@/graft.config";
export const { db } = createDb(process.env.DATABASE_URL!);
export const graft = createGraft({ db, collections });
```
The Postgres version also exports `db`, because the function and MCP routes
below need it.
In the App Router, every read goes through `React.cache`. If a layout and a
page both read the same document during one request, the index is read once.
The Pages Router reads in one data function per page, so there is nothing to
share.
## Show a document
A page reads its document by slug and renders the body with ``.
`getContent` returns `null` for a slug that does not exist, so send that to
`notFound()`:
`app/[slug]/page.tsx`:
```tsx
import { notFound } from "next/navigation";
import { MdxBody } from "@usegraft/sdk-next";
import { graft } from "@/lib/graft";
// Optional: build every page ahead of time.
export async function generateStaticParams() {
const pages = await graft.listContent("pages");
return pages.map((page) => ({ slug: page.slug }));
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const page = await graft.getContent("pages", slug);
if (!page) notFound();
return (
{page.data.title}
{page.data.tagline &&
{page.data.tagline}
}
);
}
```
`page.data` is typed from the `pages` collection in your schema, so a misspelled
field is a compile error, not an empty heading.
## Use your own components in Markdown
Pass `components` to `` to render a tag in your content with your
own React component. This content uses a `Callout`:
`content/pages/home.mdx`:
```mdx
Prices change on 1 March.
```
And this renders it:
`components/document-body.tsx`:
```tsx
import type { ReactNode } from "react";
import { MdxBody } from "@usegraft/sdk-next";
const components = {
Callout: ({ title, children }: { title?: string; children: ReactNode }) => (
),
};
export function DocumentBody({ source }: { source: string }) {
return ;
}
```
> **Content cannot run code.**
> By default `` refuses `{…}` expressions, `import`, and `export`,
> because those would run as JavaScript on your server. Components with plain
> string attributes, like the `Callout` above, are allowed. See
> [MDX safety](/docs/mdx-safety).
## Use the Pages Router
The Pages Router reads content in `getStaticProps` or `getServerSideProps` and
renders in a component that also runs in the browser. Two things change from
the App Router: how a body is rendered, and how pages refresh.
| | App Router | Pages Router |
| --- | --- | --- |
| Where you read | Server Components and route handlers | `getStaticProps`, `getServerSideProps` and API routes |
| Render a body | `` | `prepareMdx` in `getStaticProps`, then `` |
| Refresh after a change | By tag, with `revalidateContent` | By path, with `res.revalidate` |
### Show a document
`prepareMdx` compiles the body on the server, with the same safety rule as
``, and returns a string. `` renders that string, in
the server render and in the browser. Import it from
`@usegraft/sdk-next/mdx`, which carries no server code, so the page's browser
bundle stays small:
`pages/[slug].tsx`:
```tsx
import type { GetStaticPaths, GetStaticProps } from "next";
import { prepareMdx } from "@usegraft/sdk-next";
import { MdxContent } from "@usegraft/sdk-next/mdx";
import { graft } from "@/lib/graft";
interface Props {
title: string;
tagline: string | null;
code: string;
}
export const getStaticPaths: GetStaticPaths = async () => {
const pages = await graft.listContent("pages");
return {
paths: pages.map((page) => ({ params: { slug: page.slug } })),
fallback: "blocking",
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const page = await graft.getContent("pages", params?.slug ?? "");
if (!page) return { notFound: true };
return {
props: {
title: page.data.title,
tagline: page.data.tagline ?? null,
code: await prepareMdx(page.body),
},
};
};
export default function Page({ title, tagline, code }: Props) {
return (
{title}
{tagline &&
{tagline}
}
);
}
```
Props must be JSON, so pass the fields the page shows rather than the whole
document: its `updatedAt` is a `Date`, which Next.js refuses to serialize.
Pass `components` to `` as you would to
[``](#use-your-own-components-in-markdown). A body that uses
`import`, `import()` or `import.meta` is refused with
`INPUT_VALIDATION_FAILED`, even with `trust: "full"`, because the browser has no
bundler to resolve it. Pass the component in `components` instead. A body that
only mentions an import, in a code span or a fenced block, is prose and renders.
> **MdxContent needs unsafe-eval in a Content Security Policy.**
> `` runs the prepared code with `new Function`, in the server
> render and again in the browser. A `script-src` without `'unsafe-eval'`, such
> as the nonce-based policy Next.js suggests, blocks it in the browser, and the
> body stops working after hydration. Add `'unsafe-eval'` to `script-src` on the
> pages that use it, or render those pages with the App Router's ``,
> which runs only on the server. With `trust: "full"`, a body's expressions run
> in visitors' browsers as well as on the server.
### Refresh Pages Router pages
> Needs the Postgres storage engine.
The Pages Router caches whole pages, not reads, so a change refreshes the
pages that show it, by path. Use this route instead of the App Router's in
[step 2 below](#refresh-pages-when-content-changes), and point
`GRAFT_REVALIDATE_URL` at it as step 3 describes:
`pages/api/revalidate.ts`:
```ts
import type { NextApiRequest, NextApiResponse } from "next";
const isStrings = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === "string");
// The page that shows each changed document. A key is "collection/slug".
function pathFor(key: string): string | null {
const [collection, slug] = key.split("/");
return collection === "pages" && slug ? `/${slug}` : null;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const secret = process.env.GRAFT_WEBHOOK_SECRET;
if (req.method !== "POST" || !secret || req.headers.authorization !== `Bearer ${secret}`) {
return res.status(401).json({ error: "Unauthorized" });
}
const changes = req.body?.changes;
const keys = [changes?.added, changes?.changed, changes?.removed];
if (!keys.every(isStrings)) {
return res.status(400).json({ error: "Send { branch, changes }." });
}
const paths = keys.flat().flatMap((key) => pathFor(key) ?? []);
await Promise.all(paths.map((path) => res.revalidate(path)));
return res.json({ revalidated: paths });
}
```
Add a line to `pathFor` for each collection with its own page, and revalidate
any page that lists a collection when a document in it changes. Step 1 below
is not needed: the Pages Router has no cached reads to tag.
## Serve functions and MCP from your app
> Needs the Postgres storage engine.
Skip this section unless you need it. Mount these routes when you want to:
- call your [typed functions](/docs/functions-and-access) at
`POST /api/fn/`, or
- let remote agents read and write content over MCP at `POST /api/mcp`.
Both need the Postgres engine and three more packages:
```sh
npm i @usegraft/core @usegraft/auth @usegraft/mcp
```
Both routes need to know who is calling. This resolver accepts one token from
your environment, which is enough to try things out. A dev token never
expires, so the resolver ignores it in production. [Auth](/docs/auth) shows
how to accept signed tokens from your identity provider.
`lib/actor.ts`:
```ts
import { createActorResolver } from "@usegraft/auth";
// A dev token never expires, so it is accepted only in development. An unset
// NODE_ENV counts as production, so a deploy that forgets it stays closed.
const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined;
export const actor = createActorResolver({
devTokens: devToken
? {
[devToken]: {
kind: "human",
id: "owner",
scopes: ["content:write", "submissions:admin"],
},
}
: undefined,
});
```
`app/api/fn/[name]/route.ts`:
```ts
import { createFunctionsHandler } from "@usegraft/core";
import { functions } from "@/graft.config";
import { actor } from "@/lib/actor";
import { db } from "@/lib/graft";
export const POST = createFunctionsHandler({ db, functions, actor });
```
`app/api/mcp/route.ts`:
```ts
import { createGraftMcpHandler } from "@usegraft/mcp";
import { collections, functions } from "@/graft.config";
import { actor } from "@/lib/actor";
import { db } from "@/lib/graft";
export const POST = createGraftMcpHandler({
contentDir: "./content",
db,
collections,
functions,
actor,
});
```
A request without a valid token gets a `401`. Never mount the MCP route with
`allowAnonymous: true` on a server that others can reach. For public,
read-only docs, use the [docs MCP](/docs/docs-mcp) instead.
## Refresh pages when content changes
> Needs the Postgres storage engine.
On the static engine, content changes ship with a new deploy, so there is
nothing to refresh. On Postgres, content can change while your app is running,
from a local Studio or from an agent. A server that writes to GitHub
([Host Studio for your team](/docs/studio-hosting)) is different: saves are
drafts. Publishing either commits to your production branch or opens a pull
request, and your host rebuilds when the change reaches that branch. A local
Studio refreshes the app after a save that changes content, once the webhook
below (or an `onContentChange` hook) is set up, and its Publish only commits in
your checkout, which you push yourself. This section makes Next.js refresh only the pages
whose content changed.
It takes two steps. How you cache reads depends on your version. Next.js 16
uses cached functions. Next.js 15 uses `unstable_cache`. The refresh
route is the same on both. On the Pages Router, use
[its own route](#refresh-pages-router-pages) instead.
**Tag each cached read**
Cache each read and tag it with `tagsFor`. Use these functions in your pages
instead of calling `graft` directly.
**Next.js 16:**
Turn on cached functions in the config you wrapped with `withGraft`:
`next.config.ts`:
```ts
import { withGraft } from "@usegraft/sdk-next/config";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// your existing Next.js options
cacheComponents: true,
};
export default withGraft(nextConfig);
```
Then wrap each read in a `'use cache'` function:
`lib/content.ts`:
```ts
import { cacheTag } from "next/cache";
import { tagsFor } from "@usegraft/sdk-next";
import { graft } from "@/lib/graft";
export async function getPage(slug: string) {
"use cache";
cacheTag(...tagsFor("main", "pages", slug));
return graft.getContent("pages", slug);
}
export async function listPages() {
"use cache";
cacheTag(...tagsFor("main", "pages"));
return graft.listContent("pages");
}
```
**Next.js 15:**
Wrap each read in `unstable_cache` and pass the tags:
`lib/content.ts`:
```ts
import { unstable_cache } from "next/cache";
import { tagsFor } from "@usegraft/sdk-next";
import { graft } from "@/lib/graft";
// unstable_cache stores results as JSON, so `updatedAt` comes back as a
// string. Turn it back into a Date, so every value matches its type.
function revive(doc: T): T {
return { ...doc, updatedAt: new Date(doc.updatedAt) };
}
export async function getPage(slug: string) {
const page = await unstable_cache(
() => graft.getContent("pages", slug),
["graft", "pages", slug],
{ tags: tagsFor("main", "pages", slug) },
)();
return page && revive(page);
}
const cachedPages = unstable_cache(() => graft.listContent("pages"), ["graft", "pages"], {
tags: tagsFor("main", "pages"),
});
export async function listPages() {
return (await cachedPages()).map(revive);
}
```
`unstable_cache` stores results as JSON, which has no date type, so
`revive` turns each `updatedAt` back into a `Date`. Schema fields are not
affected: `field.datetime()` values are ISO strings already.
Without this step, `revalidateContent` has nothing to refresh and does
nothing.
**Add a route that refreshes what changed**
`revalidateContent` takes the list of documents a compile changed and refreshes
the matching tags: each changed document, and the collection it is in.
`app/api/revalidate/route.ts`:
```ts
import { revalidateContent, type ChangeSet } from "@usegraft/sdk-next";
const isStrings = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === "string");
function isChangeSet(value: unknown): value is ChangeSet {
const v = value as Partial | null;
return (
isStrings(v?.added) &&
isStrings(v?.changed) &&
isStrings(v?.removed) &&
typeof v?.unchanged === "number"
);
}
export async function POST(request: Request) {
const secret = process.env.GRAFT_WEBHOOK_SECRET;
if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => null);
const branch = body?.branch ?? "main";
if (typeof branch !== "string" || !isChangeSet(body?.changes)) {
return Response.json({ error: "Send { branch, changes }." }, { status: 400 });
}
const tags = revalidateContent(branch, body.changes);
return Response.json({ revalidated: tags });
}
```
**Point Graft at the route**
Add two variables to the `.env` Graft reads, or to the environment, wherever
`graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate`
runs. The secret must match the one the route checks:
`.env`:
```sh
GRAFT_REVALIDATE_URL=https://example.com/api/revalidate
GRAFT_WEBHOOK_SECRET=a-long-random-string
```
The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the
app's runtime environment, such as your host's environment variables.
Otherwise the route answers 401 and nothing refreshes.
Graft now calls the route after every write that changes content: an agent's
`write_content` or `delete_content`, a local Studio save, create, delete,
discard, compile or revert, and
`graft merge --apply` or `graft migrate --apply`. The URL must use https, except
on loopback (`localhost`, `127.0.0.1`, `[::1]`).
If the route fails, the write still succeeds. The server logs a
`REVALIDATE_FAILED` error, and an agent's result carries
`refresh: { ok: false, fix }`, so the agent knows not to retry the write. To
refresh those pages, POST the body the server logged with the error to the
route. Recompiling does not help: the index already has the change, so a new
compile reports those documents as unchanged.
If your app mounts the MCP endpoint itself, skip the webhook. Agent writes run
in your app's process, so refresh there:
`app/api/mcp/route.ts`:
```ts
import { createGraftMcpHandler } from "@usegraft/mcp";
import { revalidateContent } from "@usegraft/sdk-next";
import { collections, functions } from "@/graft.config";
import { actor } from "@/lib/actor";
import { db } from "@/lib/graft";
export const POST = createGraftMcpHandler({
contentDir: "./content",
db,
collections,
functions,
actor,
// An agent's write lands in this app's process, so refresh the cache here.
onContentChange: ({ branch, changes }) => {
revalidateContent(branch, changes);
},
});
```
`graft compile` never calls the route: a deploy compiles before the new version
is live. To refresh after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET`
exported in its environment, which is how most CI systems expose secrets.
The script stops if the compile fails, and `--fail` makes a refused request
fail the script too:
```sh
graft compile --json > changes.json || exit 1
curl --fail -X POST https://example.com/api/revalidate \
-H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \
-H "content-type: application/json" \
--data-binary @changes.json
```
Either way, the route reads a body shaped like this:
```json
{
"branch": "main",
"gitSha": "b8eb719a468be404bae9adca4fbea4d90c642411",
"changes": { "added": ["pages/pricing"], "changed": ["pages/home"], "removed": [], "unchanged": 12 }
}
```
To refresh inside the same request instead, for example from a Server Action
that saves and then shows the result, use `updateContent(branch, changes)`.
It needs Next.js 16. On 15 it throws `FRAMEWORK_VERSION_UNSUPPORTED`,
whose fix points you back to `revalidateContent`.
## Run Graft on Next.js 14
Graft does not support Next.js 14, and you should not run it in production.
This section is for a project that has to adopt Graft before it can upgrade
Next.js. The adapter still runs on 14.2, but CI no longer tests it. The last
release tested on 14.2.35 is 1.0.0-beta.8.
> **Next.js 14 has security advisories that will never be fixed.**
> 14.2.35 is the last Next.js 14 release, and it no longer gets security
> fixes. 23 published Next.js advisories affect it. Two are critical: remote
> code execution through the image optimization API, and remote code execution
> on servers hosted on Windows. Every one is fixed only in 15 and 16.
What changes from the steps above:
- **Install.** The adapter's peer range is `next@^15.0.0 || ^16.0.0`. pnpm and
bun install anyway and print a peer warning. npm refuses with `ERESOLVE`
unless you pass `--legacy-peer-deps`:
```sh
npm i @usegraft/sdk-next @usegraft/db --legacy-peer-deps
```
- **Config.** Next.js 14 cannot load a TypeScript config. Name the file
`next.config.mjs`. `withGraft` writes the externals setting under the name
14 reads, `experimental.serverComponentsExternalPackages`:
`next.config.mjs`:
```js
import { withGraft } from "@usegraft/sdk-next/config";
export default withGraft({
// your existing Next.js options
});
```
- **The static index.** Set `"target": "ES2017"` in `tsconfig.json`, which
the static index version of `lib/graft.ts` needs on 14.
- **Cached reads.** Use the Next.js 15 tab of
[Refresh pages when content changes](#refresh-pages-when-content-changes):
`unstable_cache` and `revalidateContent` work the same on 14.
`updateContent` throws `FRAMEWORK_VERSION_UNSUPPORTED`.
- **The Pages Router** runs on React 18 there. Reads are not deduped, because
React 18 has no `React.cache`, and each data function reads once per page
anyway.
`withGraft` prints this each time Next.js loads your config:
```text
graft: Graft does not support Next.js 14. It no longer gets security fixes, and 14.2.35 has critical advisories that will not be fixed on 14. Upgrade to 15.5.24 or 16.3.3 or later. Until you can, see https://graft.page/docs/next#run-graft-on-nextjs-14.
```
## Next steps
- [Schema](/docs/schema) defines the collections and fields your pages read.
- [Reading content](/docs/reading-content) covers lists, search, and
pagination.
- [Connecting agents](/docs/connecting-agents) points Cursor or Claude at your
project.
---
# Astro
> Read Graft content in Astro page frontmatter, ship no client JavaScript, and serve functions from Astro routes.
*Render Graft content in an Astro site that ships no client JavaScript.*
This guide adds Graft to an Astro project. You read content in a page's
frontmatter, so it is fetched at build or request time and the page ships no
client JavaScript for it. You can also serve Graft's functions and MCP endpoint
from Astro routes.
**Before you start**, you need an Astro project and a `graft.config.ts` with
compiled content. If you do not have those yet, run through
[Getting started](/docs/getting-started) first.
> **Prompt for a coding agent:**
> Add Graft to this Astro site following https://graft.page/docs/astro.md.
> Create the client in src/lib/graft.ts and render the pages collection at
> src/pages/[slug].astro.
## Install
**pnpm:**
```sh
pnpm add @usegraft/sdk-astro @usegraft/db
```
**npm:**
```sh
npm i @usegraft/sdk-astro @usegraft/db
```
**bun:**
```sh
bun add @usegraft/sdk-astro @usegraft/db
```
Create the client once in a server-only module, then import it from frontmatter,
endpoints, and middleware. Never from a client island.
## Create the Graft client
`graft init` scaffolds the static index. This docs site is that path:
`src/lib/graft.ts`:
```ts
import { openStaticIndex } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-astro";
import { collections } from "../../graft.config";
export const graft = createGraft({
index: await openStaticIndex(".graft/index.db"),
collections,
});
```
On Postgres:
```ts
import { createDb } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-astro";
import { collections } from "../../graft.config";
export const db = createDb(process.env.DATABASE_URL!).db;
export const graft = createGraft({ db, collections });
```
## Read a document
`src/pages/[slug].astro`:
```astro
---
import { graft } from "../lib/graft";
const page = await graft.getContent("pages", Astro.params.slug!);
---
```
Astro frontmatter always runs server-side. No other adapter can promise that.
Unlike Next.js, Astro has no built-in way to share one read across a request,
so reading the same document twice reads the index twice. That is rarely worth
worrying about: prerendered pages read once at build time, and a server-rendered
page makes only a few reads.
> **Rendering the body.**
> Bodies come back as authored source. Render them with Astro's own MDX pipeline.
> There is no `MdxBody` here. That evaluator is Next-only.
## Serve functions and MCP from your app
This step is optional, and it needs the Postgres engine: functions and the
audit log they write live in the database. The routes below import the `db`
exported by the Postgres version of `src/lib/graft.ts` above. The static setup
exports no `db`, so these routes do not work on it.
It also needs three more packages:
```sh
npm i @usegraft/core @usegraft/auth @usegraft/mcp
```
`graftRoute` binds a Graft handler to an Astro endpoint. It takes the handler,
not a config object: Astro hands over an `APIContext`, and the handlers want
its `request`.
`src/pages/api/fn/[name].ts`:
```ts
import { createFunctionsHandler } from "@usegraft/core";
import { graftRoute } from "@usegraft/sdk-astro";
import { functions } from "../../../graft.config";
import { db } from "../../lib/graft";
const handler = createFunctionsHandler({ db, functions });
export const POST = graftRoute(handler);
export const GET = graftRoute(handler); // 405s with Allow and a fix
```
`src/pages/api/mcp.ts`:
```ts
import { createActorResolver } from "@usegraft/auth";
import { createGraftMcpHandler } from "@usegraft/mcp";
import { graftRoute } from "@usegraft/sdk-astro";
import { collections, functions } from "../../graft.config";
import { db } from "../lib/graft";
// A dev token never expires, so it is accepted only in development. An unset
// NODE_ENV counts as production, so a deploy that forgets it stays closed.
const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined;
const actor = createActorResolver({
issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "")
.split(/[,\s]+/)
.filter(Boolean)
.map((issuer) => ({ issuer })),
devTokens: devToken
? {
[devToken]: {
kind: "human",
id: "owner",
scopes: ["content:write"],
},
}
: undefined,
});
export const POST = graftRoute(
createGraftMcpHandler({
contentDir: "./content",
db,
collections,
functions,
actor,
}),
);
```
This endpoint serves content writes and asset uploads. JWT verification runs
only when `issuers` is passed; the library does not read
`GRAFT_TRUSTED_ISSUERS` (`graft serve` does). See [Auth](/docs/auth).
Unauthenticated callers get 401. `allowAnonymous: true` is only for a
loopback local server. Never anything reachable from a network. A public
docs surface is [`createDocsMcpHandler`](/docs/docs-mcp), which has no write
tools and no anonymous opt-in.
The parameter is typed structurally as `{ request: Request }`, so this package
needs no `astro` dependency.
## Refresh pages when content changes
> Needs the Postgres storage engine.
On the static engine, content changes ship with a new deploy, so there is
nothing to refresh. On Postgres, content can change while your app is running,
from a local Studio or from an agent. A server that writes to GitHub
([Host Studio for your team](/docs/studio-hosting)) is different: saves are
drafts. Publishing either commits to your production branch or opens a pull
request, and your host rebuilds when the change reaches that branch. A local
Studio refreshes the app after a save that changes content, once the webhook
below (or an `onContentChange` hook) is set up, and its Publish only commits in
your checkout, which you push yourself. Astro has no tag-based data cache, so let your
CDN cache pages and purge only the ones whose content changed.
First, send each page with a header that lists the content it read. `tagsFor`
builds that list:
```astro
---
import { tagsFor } from "@usegraft/sdk-astro";
import { graft } from "../lib/graft";
const slug = Astro.params.slug!;
const page = await graft.getContent("pages", slug);
Astro.response.headers.set("Cache-Tag", tagsFor("main", "pages", slug).join(","));
---
```
Then, when content changes, purge those tags at your CDN.
`tagsForChanges(branch, changes)` turns a change list into the tags to purge:
each changed document, and the collection it is in. Graft does not purge your
CDN itself. Add a route that reads `branch` and `changes` from a POST body,
checks a bearer secret, and purges the tags `tagsForChanges` returns.
Then point Graft at the route. Add two variables to the `.env` Graft reads, or to the environment, wherever
`graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate`
runs. The secret must match the one the route checks:
`.env`:
```sh
GRAFT_REVALIDATE_URL=https://example.com/api/revalidate
GRAFT_WEBHOOK_SECRET=a-long-random-string
```
The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the
app's runtime environment, such as your host's environment variables.
Otherwise the route answers 401 and nothing refreshes.
Graft now calls the route after every write that changes content: an agent's
`write_content` or `delete_content`, a local Studio save, create, delete,
discard, compile or revert, and
`graft merge --apply` or `graft migrate --apply`. If the route fails, the write
still succeeds, and the server logs a `REVALIDATE_FAILED` error with the body to
resend. Recompiling does not resend it: a new compile reports those documents
as unchanged. If your app
mounts the MCP endpoint itself, pass `onContentChange` to
`createGraftMcpHandler` and purge there instead.
`graft compile` never calls the route: a deploy compiles before the new version
is live. To purge after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET`
exported in its environment, which is how most CI systems expose secrets.
The script stops if the compile fails, and `--fail` makes a refused request
fail the script too:
```sh
graft compile --json > changes.json || exit 1
curl --fail -X POST https://example.com/api/revalidate \
-H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \
-H "content-type: application/json" \
--data-binary @changes.json
```
`Cache-Tag` is the header Cloudflare reads, with tags separated by commas.
Fastly reads `Surrogate-Key`, with tags separated by spaces. Use the header
your CDN expects.
## Next steps
- [Reading content](/docs/reading-content) covers list and search reads.
- [SDK reference](/docs/sdk-reference) compares route mounts across frameworks.
- [Connecting agents](/docs/connecting-agents) points an agent at your project.
---
# SvelteKit
> Read Graft content in SvelteKit server load functions and serve functions from +server.ts routes.
*Read Graft content in SvelteKit server load functions.*
This guide adds Graft to a SvelteKit project. You read content in server
`load` functions, render it in your pages, and can serve Graft's functions and
MCP endpoint from `+server.ts` routes.
**Before you start**, you need a SvelteKit project and a `graft.config.ts`
with compiled content. If you do not have those yet, run through
[Getting started](/docs/getting-started) first.
> **Prompt for a coding agent:**
> Add Graft to this SvelteKit app following
> https://graft.page/docs/sveltekit.md. Keep the client in
> src/lib/server/graft.ts and read pages in +page.server.ts.
## Install
**pnpm:**
```sh
pnpm add @usegraft/sdk-sveltekit @usegraft/db
```
**npm:**
```sh
npm i @usegraft/sdk-sveltekit @usegraft/db
```
**bun:**
```sh
bun add @usegraft/sdk-sveltekit @usegraft/db
```
Create the client in `$lib/server/`, so SvelteKit's server-only enforcement
guards it. The client holds a database connection and must never reach the browser.
## Create the Graft client
`src/lib/server/graft.ts`:
```ts
import { DATABASE_URL } from "$env/static/private";
import { createDb } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-sveltekit";
import { collections } from "../../../graft.config";
export const db = createDb(DATABASE_URL).db;
export const graft = createGraft({ db, collections });
```
Static index instead of Postgres:
```ts
import { openStaticIndex } from "@usegraft/db";
export const graft = createGraft({
index: await openStaticIndex(".graft/index.db"),
collections,
});
```
> **Keep the client on the server.**
> Read from `+page.server.ts` or `+layout.server.ts`. A **universal** `load` in
> `+page.ts` or `+layout.ts` also runs in the browser on client-side navigation,
> so a database connection does not belong there.
>
> `node:sqlite` backs the static index and must stay external to the client
> bundle. Keeping the client in `$lib/server/` is what guarantees that.
## Read a document
`src/routes/[slug]/+page.server.ts`:
```ts
import { error } from "@sveltejs/kit";
import { graft } from "$lib/server/graft";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
const page = await graft.getContent("pages", params.slug);
if (!page) error(404);
return { page };
};
```
## Serve functions and MCP from your app
This step is optional: it needs the Postgres engine and three more packages:
```sh
npm i @usegraft/core @usegraft/auth @usegraft/mcp
```
`src/routes/api/fn/[name]/+server.ts`:
```ts
import { createFunctionsHandler } from "@usegraft/core";
import { graftRoute } from "@usegraft/sdk-sveltekit";
import { functions } from "../../../../graft.config";
import { db } from "$lib/server/graft";
const handler = createFunctionsHandler({ db, functions });
export const POST = graftRoute(handler);
export const GET = graftRoute(handler); // 405s with Allow and a fix
```
`src/routes/api/mcp/+server.ts`:
```ts
import { createActorResolver } from "@usegraft/auth";
import { createGraftMcpHandler } from "@usegraft/mcp";
import { graftRoute } from "@usegraft/sdk-sveltekit";
import { collections, functions } from "../../../../graft.config";
import { db } from "$lib/server/graft";
// A dev token never expires, so it is accepted only in development. An unset
// NODE_ENV counts as production, so a deploy that forgets it stays closed.
const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined;
const actor = createActorResolver({
issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "")
.split(/[,\s]+/)
.filter(Boolean)
.map((issuer) => ({ issuer })),
devTokens: devToken
? {
[devToken]: {
kind: "human",
id: "owner",
scopes: ["content:write"],
},
}
: undefined,
});
export const POST = graftRoute(
createGraftMcpHandler({
contentDir: "./content",
db,
collections,
functions,
actor,
}),
);
```
This endpoint serves content writes and asset uploads. JWT verification runs
only when `issuers` is passed; the library does not read
`GRAFT_TRUSTED_ISSUERS` (`graft serve` does). See [Auth](/docs/auth).
Unauthenticated callers get 401. `allowAnonymous: true` is only for a
loopback local server. Never anything reachable from a network. A public
docs surface is [`createDocsMcpHandler`](/docs/docs-mcp), which has no write
tools and no anonymous opt-in.
The parameter is typed structurally as `{ request: Request }`, so this package
needs no `@sveltejs/kit` dependency.
## Refresh pages when content changes
> Needs the Postgres storage engine.
On the static engine, content changes ship with a new deploy, so there is
nothing to refresh. On Postgres, content can change while your app is running,
from a local Studio or from an agent. A server that writes to GitHub
([Host Studio for your team](/docs/studio-hosting)) is different: saves are
drafts. Publishing either commits to your production branch or opens a pull
request, and your host rebuilds when the change reaches that branch. A local
Studio refreshes the app after a save that changes content, once the webhook
below (or an `onContentChange` hook) is set up, and its Publish only commits in
your checkout, which you push yourself. SvelteKit has no tag-based data cache, so let your
CDN cache pages and purge only the ones whose content changed.
First, send each page with a header that lists the content it read. `tagsFor`
builds that list:
```ts
import { tagsFor } from "@usegraft/sdk-sveltekit";
export const load: PageServerLoad = async ({ params, setHeaders }) => {
const page = await graft.getContent("pages", params.slug);
setHeaders({ "Cache-Tag": tagsFor("main", "pages", params.slug).join(",") });
return { page };
};
```
Then, when content changes, purge those tags at your CDN.
`tagsForChanges(branch, changes)` turns a change list into the tags to purge:
each changed document, and the collection it is in. Graft does not purge your
CDN itself. Add a route that reads `branch` and `changes` from a POST body,
checks a bearer secret, and purges the tags `tagsForChanges` returns.
Then point Graft at the route. Add two variables to the `.env` Graft reads, or to the environment, wherever
`graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate`
runs. The secret must match the one the route checks:
`.env`:
```sh
GRAFT_REVALIDATE_URL=https://example.com/api/revalidate
GRAFT_WEBHOOK_SECRET=a-long-random-string
```
The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the
app's runtime environment, such as your host's environment variables.
Otherwise the route answers 401 and nothing refreshes.
Graft now calls the route after every write that changes content: an agent's
`write_content` or `delete_content`, a local Studio save, create, delete,
discard, compile or revert, and
`graft merge --apply` or `graft migrate --apply`. If the route fails, the write
still succeeds, and the server logs a `REVALIDATE_FAILED` error with the body to
resend. Recompiling does not resend it: a new compile reports those documents
as unchanged. If your app
mounts the MCP endpoint itself, pass `onContentChange` to
`createGraftMcpHandler` and purge there instead.
`graft compile` never calls the route: a deploy compiles before the new version
is live. To purge after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET`
exported in its environment, which is how most CI systems expose secrets.
The script stops if the compile fails, and `--fail` makes a refused request
fail the script too:
```sh
graft compile --json > changes.json || exit 1
curl --fail -X POST https://example.com/api/revalidate \
-H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \
-H "content-type: application/json" \
--data-binary @changes.json
```
`Cache-Tag` is the header Cloudflare reads, with tags separated by commas.
Fastly reads `Surrogate-Key`, with tags separated by spaces. Use the header
your CDN expects.
## Next steps
- [Reading content](/docs/reading-content) covers list and search reads.
- [SDK reference](/docs/sdk-reference) compares route mounts across frameworks.
- [Connecting agents](/docs/connecting-agents) points an agent at your project.
---
# TanStack Start
> Read Graft content in TanStack Start through server functions, never in isomorphic loaders.
*Read Graft content in TanStack Start through server functions.*
This guide adds Graft to a TanStack Start project. You read content
through server functions, so the database or index file never reaches the
browser, and you can serve Graft's functions and MCP endpoint from server routes.
**Before you start**, you need a TanStack Start project and a
`graft.config.ts` with compiled content. If you do not have those yet, run
through [Getting started](/docs/getting-started) first.
> **Prompt for a coding agent:**
> Add Graft to this TanStack Start app following
> https://graft.page/docs/tanstack-start.md. Create the client in
> src/lib/graft.server.ts and read content through a createServerFn, never in
> a route loader.
## Install
**pnpm:**
```sh
pnpm add @usegraft/sdk-tanstack-start @usegraft/db
```
**npm:**
```sh
npm i @usegraft/sdk-tanstack-start @usegraft/db
```
**bun:**
```sh
bun add @usegraft/sdk-tanstack-start @usegraft/db
```
Create the client once in a server-only module, then reach it from server
functions and server routes.
## Create the Graft client
`src/lib/graft.server.ts`:
```ts
import { createDb } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-tanstack-start";
import { collections } from "../../graft.config";
export const db = createDb(process.env.DATABASE_URL!).db;
export const graft = createGraft({ db, collections });
```
Static index instead of Postgres:
```ts
import { openStaticIndex } from "@usegraft/db";
export const graft = createGraft({
index: await openStaticIndex(".graft/index.db"),
collections,
});
```
> **Do not read in a route loader.**
> A route `loader` is the wrong place for this client. TanStack Start loaders
> are **isomorphic**: they run on the server for the first paint and in the
> browser on client-side navigation. A client that holds a database connection
> belongs in neither the client bundle nor the browser, which is what
> `createServerFn` is for.
## Read a document
`src/routes/docs.$slug.tsx`:
```ts
import { createServerFn } from "@tanstack/react-start";
import { graft } from "../lib/graft.server";
const getDoc = createServerFn({ method: "GET" })
.validator((slug: string) => slug)
.handler(({ data: slug }) => graft.getContent("docs", slug));
```
Each call reads the index. If one request reads the same document in several
places, cache the result yourself.
## Serve functions and MCP from your app
This step is optional: it needs the Postgres engine and three more packages:
```sh
npm i @usegraft/core @usegraft/auth @usegraft/mcp
```
Copy these mounts as written. The server-route factory name has moved across
TanStack Start releases; the handler signature has not.
`src/routes/api/fn/$name.ts`:
```ts
import { createServerFileRoute } from "@tanstack/react-start/server";
import { createFunctionsHandler } from "@usegraft/core";
import { graftRoute } from "@usegraft/sdk-tanstack-start";
import { functions } from "../../../graft.config";
import { db } from "../../lib/graft.server";
const handler = createFunctionsHandler({ db, functions });
export const ServerRoute = createServerFileRoute("/api/fn/$name").methods({
POST: graftRoute(handler),
GET: graftRoute(handler), // 405s with Allow and a fix
});
```
`src/routes/api/mcp.ts`:
```ts
import { createActorResolver } from "@usegraft/auth";
import { createServerFileRoute } from "@tanstack/react-start/server";
import { createGraftMcpHandler } from "@usegraft/mcp";
import { graftRoute } from "@usegraft/sdk-tanstack-start";
import { collections, functions } from "../../graft.config";
import { db } from "../lib/graft.server";
// A dev token never expires, so it is accepted only in development. An unset
// NODE_ENV counts as production, so a deploy that forgets it stays closed.
const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined;
const actor = createActorResolver({
issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "")
.split(/[,\s]+/)
.filter(Boolean)
.map((issuer) => ({ issuer })),
devTokens: devToken
? {
[devToken]: {
kind: "human",
id: "owner",
scopes: ["content:write"],
},
}
: undefined,
});
export const ServerRoute = createServerFileRoute("/api/mcp").methods({
POST: graftRoute(
createGraftMcpHandler({
contentDir: "./content",
db,
collections,
functions,
actor,
}),
),
});
```
This endpoint serves content writes and asset uploads. JWT verification runs
only when `issuers` is passed; the library does not read
`GRAFT_TRUSTED_ISSUERS` (`graft serve` does). See [Auth](/docs/auth).
Unauthenticated callers get 401. `allowAnonymous: true` is only for a
loopback local server. Never anything reachable from a network. A public
docs surface is [`createDocsMcpHandler`](/docs/docs-mcp), which has no write
tools and no anonymous opt-in.
The parameter is typed structurally as `{ request: Request }`, so this package
needs no `@tanstack/react-start` dependency.
## Refresh pages when content changes
> Needs the Postgres storage engine.
On the static engine, content changes ship with a new deploy, so there is
nothing to refresh. On Postgres, content can change while your app is running,
from a local Studio or from an agent. A server that writes to GitHub
([Host Studio for your team](/docs/studio-hosting)) is different: saves are
drafts. Publishing either commits to your production branch or opens a pull
request, and your host rebuilds when the change reaches that branch. A local
Studio refreshes the app after a save that changes content, once the webhook
below (or an `onContentChange` hook) is set up, and its Publish only commits in
your checkout, which you push yourself. TanStack Start has no tag-based data cache, so let your
CDN cache pages and purge only the ones whose content changed.
First, send each page with a header that lists the content it read. `tagsFor`
builds that list:
```ts
import { createServerFn } from "@tanstack/react-start";
import { setResponseHeader } from "@tanstack/react-start/server";
import { tagsFor } from "@usegraft/sdk-tanstack-start";
import { graft } from "../lib/graft.server";
const getDoc = createServerFn({ method: "GET" })
.validator((slug: string) => slug)
.handler(({ data: slug }) => {
setResponseHeader("Cache-Tag", tagsFor("main", "docs", slug).join(","));
return graft.getContent("docs", slug);
});
```
The header goes on whichever response ran the function. On a full page load
that is the page's HTML. On client-side navigation, the browser calls the
server function directly, so it is that `GET` response. Let your CDN cache both:
they carry the same tags, so one purge refreshes both.
Then, when content changes, purge those tags at your CDN.
`tagsForChanges(branch, changes)` turns a change list into the tags to purge:
each changed document, and the collection it is in. Graft does not purge your
CDN itself. Add a route that reads `branch` and `changes` from a POST body,
checks a bearer secret, and purges the tags `tagsForChanges` returns.
Then point Graft at the route. Add two variables to the `.env` Graft reads, or to the environment, wherever
`graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate`
runs. The secret must match the one the route checks:
`.env`:
```sh
GRAFT_REVALIDATE_URL=https://example.com/api/revalidate
GRAFT_WEBHOOK_SECRET=a-long-random-string
```
The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the
app's runtime environment, such as your host's environment variables.
Otherwise the route answers 401 and nothing refreshes.
Graft now calls the route after every write that changes content: an agent's
`write_content` or `delete_content`, a local Studio save, create, delete,
discard, compile or revert, and
`graft merge --apply` or `graft migrate --apply`. If the route fails, the write
still succeeds, and the server logs a `REVALIDATE_FAILED` error with the body to
resend. Recompiling does not resend it: a new compile reports those documents
as unchanged. If your app
mounts the MCP endpoint itself, pass `onContentChange` to
`createGraftMcpHandler` and purge there instead.
`graft compile` never calls the route: a deploy compiles before the new version
is live. To purge after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET`
exported in its environment, which is how most CI systems expose secrets.
The script stops if the compile fails, and `--fail` makes a refused request
fail the script too:
```sh
graft compile --json > changes.json || exit 1
curl --fail -X POST https://example.com/api/revalidate \
-H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \
-H "content-type: application/json" \
--data-binary @changes.json
```
`Cache-Tag` is the header Cloudflare reads, with tags separated by commas.
Fastly reads `Surrogate-Key`, with tags separated by spaces. Use the header
your CDN expects.
## Next steps
- [Reading content](/docs/reading-content) covers list and search reads.
- [SDK reference](/docs/sdk-reference) compares route mounts across frameworks.
- [Connecting agents](/docs/connecting-agents) points an agent at your project.
---
# React Router
> Read Graft content in React Router framework mode loaders and serve functions from resource routes.
*Read Graft content in React Router framework mode.*
This guide adds Graft to a React Router app in framework mode. You read
content in route loaders on the server, and you can serve Graft's functions and
MCP endpoint from resource routes.
**Before you start**, you need a React Router app in framework mode and a
`graft.config.ts` with compiled content. If you do not have those yet, run
through [Getting started](/docs/getting-started) first.
> **Prompt for a coding agent:**
> Add Graft to this React Router app following
> https://graft.page/docs/react-router.md. Create the client in
> app/lib/graft.server.ts and read the pages collection in a route loader.
## Install
**pnpm:**
```sh
pnpm add @usegraft/sdk-react-router @usegraft/db
```
**npm:**
```sh
npm i @usegraft/sdk-react-router @usegraft/db
```
**bun:**
```sh
bun add @usegraft/sdk-react-router @usegraft/db
```
For framework mode, which is where React Router runs loaders and actions on a
server. Create the client once in a `.server.ts` module.
## Create the Graft client
`app/lib/graft.server.ts`:
```ts
import { createDb } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-react-router";
import { collections } from "../../graft.config";
export const db = createDb(process.env.DATABASE_URL!).db;
export const graft = createGraft({ db, collections });
```
Static index instead of Postgres:
```ts
import { openStaticIndex } from "@usegraft/db";
export const graft = createGraft({
index: await openStaticIndex(".graft/index.db"),
collections,
});
```
## Read a document
`app/routes/docs.$slug.tsx`:
```ts
import { graft } from "../lib/graft.server";
import type { Route } from "./+types/docs.$slug";
export async function loader({ params }: Route.LoaderArgs) {
return { doc: await graft.getContent("docs", params.slug) };
}
```
`loader` and `action` run on the server and React Router strips them from the
browser bundle. The `.server.ts` name is belt and braces on top of that: it
turns a stray client import into a build error rather than a database URL in
a bundle.
To read content in the browser (a search box, an editor preview), use
[the React adapter](/docs/react).
## Serve functions and MCP from your app
This step is optional: it needs the Postgres engine and three more packages:
```sh
npm i @usegraft/core @usegraft/auth @usegraft/mcp
```
`graftRoute` binds a Graft handler to a resource route. That is a route module with
no default export, whose loader and action return raw Responses.
`app/routes/api.fn.$name.ts`:
```ts
import { createFunctionsHandler } from "@usegraft/core";
import { graftRoute } from "@usegraft/sdk-react-router";
import { functions } from "../../graft.config";
import { db } from "../lib/graft.server"; // export the same db you passed to createGraft
const handler = createFunctionsHandler({ db, functions });
export const action = graftRoute(handler); // POST
export const loader = graftRoute(handler); // GET, which 405s with Allow and a fix
```
`app/routes/api.mcp.ts`:
```ts
import { createActorResolver } from "@usegraft/auth";
import { createGraftMcpHandler } from "@usegraft/mcp";
import { graftRoute } from "@usegraft/sdk-react-router";
import { collections, functions } from "../../graft.config";
import { db } from "../lib/graft.server";
// A dev token never expires, so it is accepted only in development. An unset
// NODE_ENV counts as production, so a deploy that forgets it stays closed.
const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined;
const actor = createActorResolver({
issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "")
.split(/[,\s]+/)
.filter(Boolean)
.map((issuer) => ({ issuer })),
devTokens: devToken
? {
[devToken]: {
kind: "human",
id: "owner",
scopes: ["content:write"],
},
}
: undefined,
});
export const action = graftRoute(
createGraftMcpHandler({
contentDir: "./content",
db,
collections,
functions,
actor,
}),
);
```
This endpoint serves content writes and asset uploads. JWT verification runs
only when `issuers` is passed; the library does not read
`GRAFT_TRUSTED_ISSUERS` (`graft serve` does). See [Auth](/docs/auth).
Unauthenticated callers get 401. `allowAnonymous: true` is only for a
loopback local server. Never anything reachable from a network. A public
docs surface is [`createDocsMcpHandler`](/docs/docs-mcp), which has no write
tools and no anonymous opt-in.
Register them the way you register any route:
`app/routes.ts`:
```ts
import { route, type RouteConfig } from "@react-router/dev/routes";
export default [
route("api/fn/:name", "routes/api.fn.$name.ts"),
route("api/mcp", "routes/api.mcp.ts"),
] satisfies RouteConfig;
```
> **Why the route exports a loader.**
> React Router handles GET requests with a route's `loader` export and all
> other methods with its `action` export, so the same Graft handler is
> exported twice. Keep the `loader` export even though functions are called
> with POST. Without it, a GET to a function URL gets React Router's generic
> "no loader" error. With it, the caller gets Graft's 405 response, which says
> which method to use.
## Refresh pages when content changes
> Needs the Postgres storage engine.
On the static engine, content changes ship with a new deploy, so there is
nothing to refresh. On Postgres, content can change while your app is running,
from a local Studio or from an agent. A server that writes to GitHub
([Host Studio for your team](/docs/studio-hosting)) is different: saves are
drafts. Publishing either commits to your production branch or opens a pull
request, and your host rebuilds when the change reaches that branch. A local
Studio refreshes the app after a save that changes content, once the webhook
below (or an `onContentChange` hook) is set up, and its Publish only commits in
your checkout, which you push yourself. React Router has no tag-based data cache, so let your
CDN cache pages and purge only the ones whose content changed.
First, send each page with a header that lists the content it read. `tagsFor`
builds that list:
```ts
import { tagsFor } from "@usegraft/sdk-react-router";
import type { Route } from "./+types/docs.$slug";
export function headers({ params }: Route.HeadersArgs) {
return { "Cache-Tag": tagsFor("main", "docs", params.slug).join(",") };
}
```
Then, when content changes, purge those tags at your CDN.
`tagsForChanges(branch, changes)` turns a change list into the tags to purge:
each changed document, and the collection it is in. Graft does not purge your
CDN itself. Add a route that reads `branch` and `changes` from a POST body,
checks a bearer secret, and purges the tags `tagsForChanges` returns.
Then point Graft at the route. Add two variables to the `.env` Graft reads, or to the environment, wherever
`graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate`
runs. The secret must match the one the route checks:
`.env`:
```sh
GRAFT_REVALIDATE_URL=https://example.com/api/revalidate
GRAFT_WEBHOOK_SECRET=a-long-random-string
```
The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the
app's runtime environment, such as your host's environment variables.
Otherwise the route answers 401 and nothing refreshes.
Graft now calls the route after every write that changes content: an agent's
`write_content` or `delete_content`, a local Studio save, create, delete,
discard, compile or revert, and
`graft merge --apply` or `graft migrate --apply`. If the route fails, the write
still succeeds, and the server logs a `REVALIDATE_FAILED` error with the body to
resend. Recompiling does not resend it: a new compile reports those documents
as unchanged. If your app
mounts the MCP endpoint itself, pass `onContentChange` to
`createGraftMcpHandler` and purge there instead.
`graft compile` never calls the route: a deploy compiles before the new version
is live. To purge after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET`
exported in its environment, which is how most CI systems expose secrets.
The script stops if the compile fails, and `--fail` makes a refused request
fail the script too:
```sh
graft compile --json > changes.json || exit 1
curl --fail -X POST https://example.com/api/revalidate \
-H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \
-H "content-type: application/json" \
--data-binary @changes.json
```
`Cache-Tag` is the header Cloudflare reads, with tags separated by commas.
Fastly reads `Surrogate-Key`, with tags separated by spaces. Use the header
your CDN expects.
## Next steps
- [Reading content](/docs/reading-content) covers list and search reads.
- [SDK reference](/docs/sdk-reference) compares route mounts across frameworks.
- [Connecting agents](/docs/connecting-agents) points an agent at your project.
---
# React
> Read Graft content from browser React code with hooks that call the Content API.
*Read Graft content from React code that runs in the browser.*
`@usegraft/sdk-react` gives you React hooks that read content over HTTP. Use it
when your React code runs in the browser and cannot open the index itself:
- a single-page app built with Vite, or
- an interactive component that loads content after the page is on screen,
such as a live search box or a filter.
The hooks call Graft's [Content API](/docs/content-api), so something has to
serve it: `graft serve`, or the Content API handler mounted in your own
backend. The browser never receives a database connection or the index file.
**Before you start**, you need a `graft.config.ts` and a running Content API.
By the end of this page you have a typed client, a component that shows one
document, and a search box.
> **Server-rendered app?**
> If you use Next.js, Astro, SvelteKit, TanStack Start, or React Router with
> loaders, read on the server with that framework's package instead. It reads
> the index directly, with no extra HTTP request.
> **Prompt for a coding agent:**
> Add Graft content to this React app following
> https://graft.page/docs/react.md. Use createGraftHooks with the Content API
> endpoint at /api/content/v1.
## Install
**npm:**
```sh
npm i @usegraft/sdk-react
```
**pnpm:**
```sh
pnpm add @usegraft/sdk-react
```
**bun:**
```sh
bun add @usegraft/sdk-react
```
It needs React 18 or 19.
## Create the client and hooks
Create the client once, pointed at your Content API, and build the hooks from
it:
`src/graft.ts`:
```ts
import { createGraft, createGraftHooks } from "@usegraft/sdk-react";
import { collections } from "../graft.config";
export const graft = createGraft({
endpoint: "https://cms.example.com/api/content/v1",
collections,
});
export const { GraftProvider, useContent, useContentList, useContentSearch } =
createGraftHooks(graft);
```
The hooks are typed from your own `graft.config.ts`, so `post.data.title` is
checked at compile time. There is no code generation step and no schema to
download.
The hooks read from this client by default, so you do not need a provider. Wrap
a part of your app in `` only when it should read
from somewhere else, such as a preview deployment's endpoint or a fake client
in tests.
## Show one document
`useContent` reads a document by collection and slug. It returns `loading`,
`error`, `data`, and a `refresh` function to try again:
`src/BlogPost.tsx`:
```tsx
import { useContent } from "./graft";
export function BlogPost({ slug }: { slug: string }) {
const { data: post, loading, error, refresh } = useContent("posts", slug);
if (loading) return
Loading…
;
if (error) {
return (
Could not load this post.
);
}
if (!post) return
Post not found.
;
return (
{post.data.title}
{post.body}
);
}
```
`data` is `null` when the collection has no document with that slug, so a
missing post is not an error. `post.body` is the Markdown or MDX source as
written. Render it with your own Markdown or MDX renderer.
## Add a search box
`useContentSearch` runs a full-text search in one collection, best match first.
An empty query returns no results and sends no request, so you can pass the
input's value straight in:
`src/SearchBox.tsx`:
```tsx
import { useState } from "react";
import { useContentSearch } from "./graft";
export function SearchBox() {
const [query, setQuery] = useState("");
const { data: hits, loading, error } = useContentSearch("docs", query, { limit: 10 });
return (
);
}
```
Each change to a non-blank `query` sends one request. For a busy endpoint,
debounce the input before you pass it on.
Each hit also has a `snippet`: the matching text with each match wrapped in
`…`. The tags come from the index and the rest of the snippet is your
content, so do not insert it with `dangerouslySetInnerHTML` unless you trust
everyone who can write content. Split on the tags instead:
```tsx
function Snippet({ text }: { text: string }) {
return (
);
}
```
## Use TanStack Query or SWR instead
The hooks keep the latest result in component state, and nothing more. If you
want shared caching, retries, or background refresh, call the client from your
data library instead. It has the same methods: `getContent`, `listContent`, and
`searchContent`.
```tsx
import { useQuery } from "@tanstack/react-query";
import { graft } from "./graft";
export function usePost(slug: string) {
return useQuery({
queryKey: ["posts", slug],
queryFn: () => graft.getContent("posts", slug),
});
}
```
## What the browser cannot do
- **Reach the database.** The client only speaks HTTP. It has no option for a
database connection, so a connection string cannot end up in your bundle.
- **Choose a branch.** Each Content API endpoint serves one branch, set by the
server. Passing `branch` to the client or to a read throws `CONFIG_INVALID`.
To read a preview branch, point a second client at the preview deployment's
endpoint.
- **Render on the server.** During server rendering the hooks return their
loading state. Content that must be in the first HTML belongs in your
framework's server package.
## Next steps
- [Content API](/docs/content-api) shows how to serve the endpoint, with
`graft serve` or mounted in your own backend.
- [Schema](/docs/schema) defines the collections your hooks are typed from.
- [SDK reference](/docs/sdk-reference) lists every method on the client.
---
# Schema
> Describe the fields of each kind of content once, and get checked files, checked inputs, and typed reads from it.
*Describe your content once, and get types everywhere from it.*
The schema says what fields each kind of content has: a page has a title and
an optional tagline, a product has a name and a price. You write it once, in
`graft.config.ts`, and Graft uses it in three places:
- `graft compile` checks every file against it, and stops with a clear error
when one does not match.
- [Typed functions](/docs/functions-and-access) check their inputs against it.
- Every read in your app is typed from it, so your editor knows each field.
It is built on [Zod](https://zod.dev), a TypeScript validation library, so
there is no code generation step. This page declares a collection, shows the
file and the typed read that go with it, and lists every field type.
## Declare a collection
A **collection** is a group of documents with the same fields, stored in
`content//`:
`graft.config.ts`:
```ts
import { defineCollection, field } from "@usegraft/core";
export const pages = defineCollection({
name: "pages",
description: "Marketing and site pages.",
fields: {
title: field.string({ maxLength: 120, description: "Headline and ." }),
tagline: field.string({ optional: true }),
image: field.asset({ optional: true }),
},
});
```
The `description`s are for agents. An agent that calls `describe_schema` reads
them to learn what each collection and field is for, so write them as you would
explain the field to a new teammate.
## What a matching file looks like
Each document is a file whose frontmatter matches the fields:
`content/pages/about.mdx`:
```mdx
---
title: About us
tagline: We build with code and AI agents.
---
The body is Markdown or MDX.
```
And your app reads it with full types:
```ts
const about = await graft.getContent("pages", "about");
about?.data.title; // string
about?.data.tagline; // string | undefined
about?.data.color; // TypeScript error: pages has no field "color"
```
## What happens when a file does not match
If a file is missing a required field, or a value has the wrong type or is too
long, `graft compile` stops with `SCHEMA_VALIDATION_FAILED`. The message names
the file and the field, so you know exactly what to fix. Nothing is written to
the index until every file passes.
## Choose a field type
| Builder | Holds | Use it for |
| --- | --- | --- |
| `field.string()` | A short string | Titles, names, slugs |
| `field.text()` | A long string | Summaries and longer text |
| `field.number()` | A number | Prices, quantities, ratings |
| `field.boolean()` | `true` or `false` | Flags such as `featured` |
| `field.datetime()` | An ISO date and time | Publish dates |
| `field.asset()` | `{ key, alt? }` | Images and files. See [Assets](/docs/assets) |
| `field.select({ options })` | One value from a fixed list | A status, a section, a size |
| `field.reference({ to })` | The slug of a document in another collection | A product's category, a post's author |
| `field.object({ fields })` | A nested group of fields | An author with a name and a URL |
| `field.array({ of })` | A list of another field | Tags, FAQ entries |
| `field.json()` | Any JSON value | A last resort. Prefer an object or array, which are checked and typed |
Every builder accepts `optional: true` and a `description`.
## Limit what a field accepts
| Option | Applies to | Effect |
| --- | --- | --- |
| `maxLength` | `string`, `text` | The longest allowed value |
| `pattern` | `string`, `text` | A regular expression the value must match |
| `min`, `max` | `number` | The allowed range |
| `int: true` | `number` | Whole numbers only |
| `maxItems` | `array` | The most entries allowed |
| `format: "money"` | `number` | An amount in the currency's minor unit (cents for USD, whole yen for JPY). Implies `int`. Studio shows and edits it as money |
| `currency` | `number` with `format: "money"` | The ISO currency code Studio shows. Defaults to `USD` |
| `label` | every field | What Studio calls the field, such as `"Price"`. The key stays the same |
Set `maxLength` on any string or text field a public form writes to, and
`maxItems` on any array, so one request cannot store megabytes. Set `max` on
numbers that get multiplied, such as a quantity times a price. `field.json()`
has no size limit, so keep it out of public forms.
## Choices and references
`field.select` limits a value to a list. Each option is a string, or a value
with the label people see:
```ts
status: field.select({
options: ["draft", { value: "active", label: "On sale" }, "archived"],
}),
```
`field.reference` stores the slug of a document in another collection. The
file stays readable (`category: summer-sale`), Studio offers a picker over the
target collection, and `describe_schema` tells an agent where to look the
value up:
```ts
category: field.reference({ to: "categories" }),
```
The slug has to name a document that exists. `graft compile` stops with
`CONTENT_REFERENCE_NOT_FOUND` when a reference points at nothing, and names
the file, the field and the missing slug. In a database-sourced project,
publishing refuses the same thing, and also refuses to publish the deletion of
a document that live documents still point at. Publish the documents that
point at it in the same step, changed to point elsewhere.
A local Studio and `write_content` check the same thing before they write, so a
refused save, bulk edit, delete or discard leaves every file as it was. A save
is checked against the documents in the index, or a file in the content folder
the index hasn't caught up with, such as one a `git pull` brought in. A
reference that was already broken somewhere else doesn't stop an unrelated
save; `graft compile` still reports it.
A reference into a collection kept in the database (`db-authoritative`) is not
checked, because its targets are records, not documents.
Both appear in `describe_schema` with their options and target, alongside each
field's `label` and limits, so an agent and Studio see the same rules the
validator applies.
## Nest fields
Combine `field.array` and `field.object` for repeated groups, such as FAQ
entries:
```ts
faqs: field.array({
of: field.object({
fields: {
question: field.string(),
answer: field.text(),
},
}),
optional: true,
}),
```
## Export your collections
Your config must export `collections`, and `functions` if you define any. The
CLI loads the file directly, so there is no build step, and your app imports
the same file for its typed reads.
If you added ready-made features with `graft add`, combine them with your own
collections using `mergePrimitives`:
`graft.config.ts`:
```ts
import { mergePrimitives } from "@usegraft/core";
import * as primitives from "./graft";
export const { collections, functions } = mergePrimitives([
{ collections: { pages }, functions: {} },
primitives,
]);
```
Two collections or functions with the same name stop the config from loading
with `CONFIG_INVALID`. Nothing is overridden silently.
[Owned primitives](/docs/owned-primitives) covers `graft add`.
## See it live: this page's own schema
This table is generated from the schema of the `docs` collection that renders
the page you are reading. The same description answers an agent's
`describe_schema` call and fills Studio's schema view, so it always matches
what the compiler enforces.
(Field table for the `docs` collection: see the HTML page.)
## Next steps
- [Reading content](/docs/reading-content) covers the three reads.
- [Assets](/docs/assets) covers image and file fields.
- [Operational data](/docs/operational-data) covers collections your users
write to.
---
# Reading content
> Get one document, list a collection, or search it, with the same three typed calls in every framework.
*Get a document, list a collection, or search it. The same three calls work in every framework.*
Once your content is compiled, your app reads it through a Graft client. This
page covers the three reads you will use everywhere, the options they take, and
how to read from a branch or from another server.
Your framework guide shows where to create the client. Every example below
assumes one called `graft`.
## Read one document
`getContent` takes a collection and a slug, and returns the document or
`null` if it does not exist:
```ts
const home = await graft.getContent("pages", "home");
if (home) {
home.data.title; // string, typed from your schema
home.body; // the Markdown or MDX body, as written
}
```
## List a collection
`listContent` returns every document in a collection. Pass `limit` and
`offset` to page through a long one:
```ts
const posts = await graft.listContent("posts", { limit: 20, offset: 0 });
```
## Search a collection
`searchContent` runs a full-text search and returns the best matches first,
each with a highlighted snippet of the matching text:
```ts
const hits = await graft.searchContent("docs", "preview branch");
```
[Search](/docs/search) explains the query syntax and how results are ranked.
Every read checks each document against your schema on the way out. Deleted
documents are skipped. A document that fails the check is not: the read throws
`SCHEMA_VALIDATION_FAILED`, naming the document and the field, and a list or
search that includes it fails as a whole. You never receive a document whose
fields do not match its type. `graft compile` runs the same check, so this only
happens when the index was built from files that no longer match the schema.
## Why the names differ outside a framework
The framework packages call these reads `getContent`, `listContent`, and
`searchContent`. Underneath, they all use `@usegraft/sdk-core`, whose methods
are named `getDocument`, `listDocuments`, and `searchDocuments`. They take
the same arguments and return the same types. Use the `sdk-core` names only
when you call it directly, in a script or a tool with no framework.
## Read from a preview branch
Pass `branch` to any read to get content from a [preview branch](/docs/branching-previews)
instead of `main`:
```ts
const page = await graft.getContent("pages", "pricing", { branch: "preview-pricing" });
```
Branches need the Postgres engine. On the static engine the option is accepted
and ignored, because each Git checkout compiles its own index.
## Read from another server
Sometimes the app that renders pages cannot open the index itself, for example
when it runs on a different host from your content. In that case, point the
client at a [Content API](/docs/content-api) served by `graft serve`. Your
reads stay exactly the same:
```ts
import { createContentApiReader } from "@usegraft/content-api";
import { createGraft } from "@usegraft/sdk-astro";
import { collections } from "../../graft.config";
export const graft = createGraft({
index: createContentApiReader({
endpoint: "https://cms.example.com/api/content/v1",
headers: { authorization: `Bearer ${process.env.GRAFT_CONTENT_TOKEN}` },
}),
collections,
});
```
Each Content API endpoint serves one branch, so a production app cannot be
switched onto preview content by a request parameter.
## Your framework
- [Next.js](/docs/next): Server Components, MDX rendering, and cache refresh.
- [Astro](/docs/astro): Frontmatter reads with no client JavaScript.
- [SvelteKit](/docs/sveltekit): Server load functions and server routes.
- [TanStack Start](/docs/tanstack-start): Server functions, never isomorphic loaders.
- [React Router](/docs/react-router): Framework-mode loaders and resource routes.
- [React in the browser](/docs/react): Hooks that read over HTTP.
---
# Search
> Add full-text search to your site, with ranked results and highlighted snippets, and no search service to run.
*Add full-text search to your site with no search service to run.*
> Works on both storage engines, static and Postgres.
Every compile also builds a full-text search index of your content. You do not
run a search service, sync anything, or write indexing code. Call
`searchContent` and you get the best matches first, each with a snippet
showing where the words matched.
## Search a collection
```ts
const hits = await graft.searchContent("docs", "preview branch");
for (const hit of hits) {
hit.slug; // which document
hit.data.title; // typed, like any read
hit.snippet; // the matching passage, with matched words in
}
```
Agents search the same way with the `search_content` MCP tool. Each of their
results also includes `sourcePath`, the file to edit.
On a [preview branch](/docs/branching-previews), search covers the branch's
changes plus everything it inherits from `main`, the same as any other read.
## What readers can type
The query syntax is the one people already use in web search engines:
| Type | Finds |
| --- | --- |
| `preview branch` | Documents with both words |
| `"preview branch"` | That exact phrase |
| `preview or draft` | Either word |
| `branch -neon` | "branch", but not "neon" |
An empty query fails with `INPUT_VALIDATION_FAILED`. On Postgres, a query made
only of common words such as "the" returns no results rather than an error,
because Postgres drops those words. The static engine keeps them, so the same
query matches the documents that contain them.
## Show the snippets safely
Snippets wrap the matched words in `` tags, on both engines. To show the
highlighting, render the snippet as HTML. Snippets come from your own content,
but if writers you do not fully trust can edit it, strip every tag except ``
first. To show plain text instead, remove the tags.
## How results are ranked
Matches in the slug count most, then matches in frontmatter text fields, then
matches in the body, on both [storage engines](/docs/tiers). The query syntax
is the same too. The scoring formula is not, so the same query finds the same
kind of matches on both but can list them in a different order:
| | Static engine | Postgres engine |
| --- | --- | --- |
| Search technology | SQLite FTS5 | Postgres full-text search (`tsvector`, GIN index) |
| Ranking | `bm25`, weighted by field | `ts_rank`, weighted by field |
| Extra setup | None | None beyond the database |
## Search your users' data
To search [operational data](/docs/operational-data), such as form submissions,
use `searchRecords` inside a typed function. It takes the same query syntax.
Like every read of user data, it only runs inside a function, so access rules
apply. It needs the Postgres engine.
## Language
Search uses English word rules on both engines: it matches "branches" when
someone types "branch". Other languages are not configurable yet.
## Next steps
- [Reading content](/docs/reading-content) covers the other two reads.
- [Operational data](/docs/operational-data) covers `searchRecords`.
---
# Assets
> Store images and other files in S3-compatible storage, reference them from content, and turn them into URLs in your app.
*Keep images and files in object storage, and point to them from your content.*
Images and other files do not belong in Git or in your content files. Graft
stores them in any S3-compatible bucket, such as Cloudflare R2, AWS S3, Tigris,
or MinIO. Your content stores only a pointer to the file: its **key** (its path
in the bucket) and optional alt text. Your app turns the key into a URL when it
renders the page.
This page sets up storage, uploads a file, references it from a page, and shows
it in your app.
## Set up storage
Add your bucket's details to `.env`:
`.env`:
```sh
S3_ENDPOINT=https://.r2.cloudflarestorage.com
S3_BUCKET=my-site-assets
S3_ACCESS_KEY=…
S3_SECRET_KEY=…
S3_PUBLIC_URL=https://assets.example.com # optional, see below
```
`S3_REGION` defaults to `auto`, which suits R2. On AWS S3, set it to your
bucket's region, such as `us-east-1`: AWS rejects requests signed for `auto`.
[Configuration](/docs/configuration#assets-s3-compatible) lists every variable.
## Add an asset field
`graft.config.ts`:
```ts
fields: {
title: field.string(),
image: field.asset({ optional: true }), // → { key: string, alt?: string }
},
```
## Upload a file
From your terminal:
```sh
graft asset put ./hero.svg pages/home/hero.svg
```
The second argument is the key. Keys are lowercase paths, and `..` is not
allowed. The command prints the frontmatter to paste into your page. The file's
content type is worked out from its name.
Agents upload with the `put_asset` MCP tool. An agent on your machine passes a
file `path`. A remote agent sends the file as `base64` with a `key`.
Both refuse a key that already holds a file, with the error `ASSET_EXISTS`.
The bucket keeps no history, so a replaced file is gone. Pick a new key for a
new version of a file. To replace the file on purpose, pass `--overwrite` to
`graft asset put`, or `overwrite: true` to `put_asset`.
## Reference it from content
`content/pages/home.mdx`:
```mdx
---
title: Home
image:
key: pages/home/hero.svg
alt: Branch grafted onto a trunk
---
```
## Show it in your app
Turn the key into a URL with `@usegraft/assets`:
`lib/assets.ts`:
```ts
import { createStorage } from "@usegraft/assets";
const storage = createStorage(); // reads the S3_* variables
export async function assetUrl(key: string): Promise {
return storage.url(key);
}
```
```tsx
const home = await graft.getContent("pages", "home");
const src = home?.data.image ? await assetUrl(home.data.image.key) : undefined;
```
If you set `S3_PUBLIC_URL`, `url()` returns a plain public URL, which browsers
and CDNs can cache. Without it, `url()` returns a signed link that works for a
short time. Use a public URL for anything on public pages.
Compiling also records which asset keys each document uses, so search and agents
can see which pages use a file.
## Next steps
- [Schema](/docs/schema) covers the other field types.
- [Configuration](/docs/configuration) lists every storage variable.
- [MCP tools](/docs/mcp-tools#put_asset) covers `put_asset` in full.
---
# Owned primitives
> Add ready-made features such as comments, SEO fields, or a product catalog as source code you own and can change.
*Add ready-made features, such as comments, as code you own and can edit.*
You want comments on your blog. You could write the collection, the functions
to post and moderate, and the access rules yourself, or you could start from
one that already works.
Graft ships ready-made features like this as source code you copy into your
project, the way shadcn/ui ships components. We call them **primitives**. There
is no plugin system and no package to keep updated: once added, the code is
yours to read and change.
## See what is available
```sh
graft add
```
| Primitive | What you get | Needs |
| --- | --- | --- |
| `comments` | A comments collection, plus functions to post, list, moderate, and delete | Postgres |
| `commerce` | Products as content, orders as runtime data, plus functions to place, list, update, and cancel orders | Postgres |
| `scoped-access` | Named permission scopes and helpers for access rules | Either engine |
| `seo` | SEO fields to add to any collection | Either engine |
| `faq` | Structured FAQ fields and a block to render them | Either engine |
| `callout` | A callout content block and its MDX component | Either engine |
Agents can browse the same list with the `list_registry` and `describe_item`
MCP tools.
## Add one
Preview what it will create, then add it:
```sh
graft add comments --dry-run # show the files, change nothing
graft add comments
```
This copies the primitive's TypeScript into your `graft/` folder, along with
anything it depends on, such as `scoped-access`. It also updates
`graft/index.ts`, a file that re-exports everything in `graft/`.
## Connect it to your config
Your config combines your own collections with everything in `graft/`, using
`mergePrimitives`. Projects created with `graft init` already do this:
`graft.config.ts`:
```ts
import { mergePrimitives } from "@usegraft/core";
import * as primitives from "./graft";
export const { collections, functions } = mergePrimitives([
{ collections: { pages }, functions: {} },
primitives,
]);
```
Run `graft compile`, and the new collection and functions are live. If a
primitive's name clashes with one of yours, the config stops loading with
`CONFIG_INVALID` instead of silently replacing either.
## Change it
The files in `graft/` are yours. Rename fields, change the moderation rules,
or delete functions you do not need. Graft does not overwrite them: running
`graft add` again refuses to replace a file you changed, unless you pass
`--overwrite`. If you need to undo your changes, Git has the original.
## Where primitives come from
Primitives ship inside the `@usegraft/registry` package, versioned with the rest
of Graft and tested with it. Each one is a small manifest: a content block, a
set of fields, access rules, or a bundle of those. Third-party registries are
not supported yet. The format is designed so they can be added later.
## Next steps
- [Schema](/docs/schema) covers combining collections.
- [Operational data](/docs/operational-data) explains the runtime data that
`comments` and `commerce` use.
- [Functions & access](/docs/functions-and-access) covers the functions they add.
---
# Studio
> An editor for people who would rather not edit files: find, create, edit and publish content in a browser, with every change kept in Git or the database.
*Give writers and merchandisers an editor, without giving up Git.*
> Works on both storage engines, static and Postgres.
Not everyone on your team wants to edit Markdown files and run Git commands.
Studio is an editor for them. They find an entry, change it in a form built from
your schema, and publish. In a project that keeps its content in Git, every
change ends up as files there, exactly as if a developer had edited them. In
one that keeps it in the database, every change is a revision in Postgres.
Nothing an editor can do in Studio is unavailable to an agent or a developer.
Studio runs on both [storage engines](/docs/tiers). Editing, saving and
committing content need no database. The views that keep their data in
Postgres (approvals, the compile history, branches, accounts, agents, the media
library and site settings) need one, and say so when it is missing. Your
content stays where your project keeps it either way.
## Open Studio
```sh
graft studio
```
This starts Studio on your machine, at port 4983, and opens your browser. Only
your machine can reach it, and saves go to the files in your checkout.
To give a team a Studio URL, with sign-in and saves that land on GitHub, see
[Host Studio for your team](/docs/studio-hosting).
### Without a database
`graft studio` starts without `DATABASE_URL`. You can find, edit, create and
delete entries, and commit them, exactly as with one.
- **In a static project** (`export const index = "static"`), each save also
updates the static index, `.graft/index.db`, so your local site shows it at
once, and Developer, Health compares your files with it. A static project
never uses a database, even when a `DATABASE_URL` is set.
- **In a Postgres project with no `DATABASE_URL`**, saves go to your files and
the index waits for your next `graft compile` with a database.
Approvals, the compile history, branches, people, agents, the media library and
site settings keep their data in Postgres. Each of those views says it needs a
database and how to add one. The Studio API answers their routes with
`NEEDS_DATABASE` (HTTP 503) and the same steps in its `fix`.
### Who signs in
A Studio started with `graft studio` is bound to your computer: it listens on
`127.0.0.1` only, and anyone who can reach that address is you. It asks nobody
to sign in, with a database or without one. To reach it from another machine,
start it with `--host` and set `GRAFT_DEV_TOKEN`; requests then need that token
as a bearer header, which is checked without a database.
Studio accounts are kept in Postgres, and so are the sessions of the people who
sign in with them, so accounts need a database. A Studio for a team runs under
`graft serve --studio`, which needs `DATABASE_URL` anyway. Without a database
there is no sign-in to offer, and none is needed, because only this computer
can open Studio.
## Find your way around
Content comes first.
| Where | What it is for |
| --- | --- |
| Home | What is waiting for you, what was published lately and by whom, and every collection with its count |
| Inbox | Changes sent for review, and agents asking before they delete or overwrite anything |
| A collection | A table of its entries: search, filter, sort, select many |
| An entry | Its fields and its content, saved as you type, between its collection's list and a panel with its details and history |
| Publish | Every unpublished change, what changed in it, and the button that puts it live |
| Developer | Index health, schema, compile history, branches and settings |
Press ⌘K anywhere to find an entry by name, create one, or jump to a
collection. Search runs on the server, so it is as quick on five thousand
products as on fifty.
In a list, J and K move down and up from anywhere on the
page, and once you are in it so do the arrow keys, Home and
End. Enter opens the entry.
## Find content
A collection opens as a table. The columns are the first few short fields of
its schema, so a product list shows price, status and category without any
configuration.
- **Search** matches names, URL names and text fields.
- **Filter** by status (all, unpublished, published) and by any choice,
yes/no or reference field.
- **Sort** by name, last edited or any column.
- **Select** several entries to publish, discard or delete them together.
The table loads a page at a time as you scroll. Search, filters and sort are
kept in the address, so a reload, the back button or a link you send shows the
same rows. The count leaves out entries you deleted: they stay in the list,
marked Deleted, until you publish the deletion.
### Change a field on many entries
Select the entries, then choose **Set a field** in the bar at the bottom of the
list. Pick the field and its value, with the same control the entry editor
uses, and Studio saves each entry as a draft. Nothing changes on your site
until you publish (or commit) them. To take an optional field off every selected
entry instead, tick **Remove … from these entries instead**. An empty value
without that box ticked is refused, so one press of Enter can't wipe a field.
Each entry is checked like any save, so a value the schema refuses is refused
here too. If some entries can't be saved, the dialog lists them with the
reason, and they stay selected so you can fix them and try again. The others
are saved. You can set a choice, a number or amount, a yes/no field, a
reference or a line of text. Lists, groups and images are edited one entry at
a time.
## Edit an entry
An entry opens as a page: its title at the top, its fields below, and its
content last.
Fields are built from your schema. Each has its label, says if it is optional,
and shows its help text. Money is typed as money, choices are buttons or a
list, references open a search over the other collection, lists of images are a
gallery, and repeated groups such as product variants fold into one line each.
See [Schema](/docs/schema) for how to declare them.
Problems are shown on the field, in words, before anything is saved: "Keep the
summary to 240 characters", "Price can't be less than $0.00". Studio does not
save an entry until it is valid, which is the same rule every agent write
follows.
The content is edited in a rich editor that saves back to Markdown. Studio only
offers it when it can save the content without changing anything you did not
touch. When it can't, the content opens as Markdown beside the same form. This
matters for MDX: a change as small as escaping `{` can break a page that looks
identical. Use **Edit source** in the entry's menu to edit the whole file.
### The list and the panel
An open entry keeps its collection's list on the left, with the entry marked,
so the next one is a click away. The arrow keys move through the list and
Enter opens an entry.
The panel on the right has three tabs:
- **Details**: whether the entry is published, where it is saved, its URL
name, file and version.
- **Changes**: what your draft changes, field by field.
- **History**: every published version, who made it and when. Open one to see
how the entry differs from it now, and press **Restore this version** to go
back to it. A restore is a draft like any other edit: nothing on the site
changes until you publish it.
History comes from wherever the content lives: the published revisions when
content is in the database, the production branch's commits on GitHub, and
your checkout's commits on your machine.
Press ⌘\ (Ctrl+\) to hide the panel for a full-width
page. On a narrower window it opens over the entry instead of beside it.
### Saving
Studio saves automatically, and never rewrites an entry you only opened. Every
save carries the version you started from. If the entry changed somewhere else
in the meantime (another tab, a teammate, an agent), Studio does not overwrite
it. It shows you, and you choose to load the newer version or keep yours.
On your machine, a save also updates the index for that entry, so your local
site shows it straight away. Studio checks and projects only the file it
saved, which keeps a save fast on a large catalog. A file you change outside
Studio reaches the index on your next `graft compile`, or at once while
`graft dev` is running.
A save rewrites only the fields you changed. The rest of the frontmatter keeps
its bytes, comments included, and an edited one-line string stays on one line,
in the quotes it had, so the diff shows your change and nothing else.
## Publish
Saved changes are drafts until you publish them. The Publish button in the top
bar shows how many are waiting, and opens a sheet that lists them as entries
("Wool Hat, Edited") with the fields that changed ("Price: $25.00 → $29.00").
Choose what to include and press the button. What it does depends on where
Studio runs:
| Studio | Publish button | What happens |
| --- | --- | --- |
| On your machine | Commit | A Git commit of the selected files. Nothing is pushed |
| On your machine, contributor | Commit, turned off | Nothing. The drafts wait for an editor to commit them |
| Hosted, editor | Publish | One commit on your production branch; the site redeploys. With `GRAFT_STUDIO_PUBLISH=pull-request`, a pull request instead |
| Hosted, contributor | Submit for review | A pull request for someone to merge |
| Content in the database, editor | Publish | The entries go live on the site straight away. Nothing to rebuild |
| Content in the database, contributor | Submit for review | A request someone who can publish approves |
When your project keeps its content in the database
(`content = { source: "database" }`, see
[Configuration](/docs/configuration#content)), there is one draft per entry,
shared by everyone who edits it, and Studio works the same on your machine and
hosted.
A commit on your machine is authored by the person signed in, when Studio has
sign-in, and committed by whoever runs Studio. Leave **Describe the change**
empty and the message names the entries, such as `Update “Wool Hat”`, or counts
them past three.
**Discard** throws a draft away and leaves the published version as it is.
Deleting an entry is a draft too: it stays live until you publish the deletion,
and the toast that follows a delete has an Undo. Until then the entry stays in
its collection marked Deleted; open it to read it or press Restore.
## Inbox
The inbox holds what is waiting on a decision:
- **Changes for review.** A contributor's Submit for review lands here. When
content is in the database, someone who can publish presses **Publish** on
the request. On GitHub, the request is a pull request, and **Open on
GitHub** takes you to it.
- **Agents asking first.** A destructive call from an agent waits until a
person approves or denies it. Nothing runs until then. The card says what it
would do, such as **Delete projects/welup-digital**, and **Approve** asks once
more before the agent may run it. See
[Audit & approvals](/docs/audit-and-approvals).
The sidebar shows how many things are waiting.
## Media
**Media** in the sidebar lists every file in the asset store that Studio
uploaded or an entry uses. Search by name or alt text, and press **Upload** to
add images or PDFs (10 MB each at most). Open a file to copy its key, write
its alt text, and see which entries use it. A file can be deleted only when
nothing uses it, by someone who can publish.
An image field has **Choose…**, which opens the same library: pick a file or
upload one. The field takes the file's alt text when it has none of its own,
and you can still word it differently for that entry.
Media needs an asset store: set `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`
and `S3_BUCKET` (any S3-compatible store) where Studio runs, and
`S3_PUBLIC_URL` for public links. Without them the library says so, and image
fields still take a key you type.
## Give people access
On your own machine Studio asks for no login. A hosted Studio has sign-in: Studio
accounts with a password, GitHub, Google, your own single sign-on provider, and
invite links. An admin adds people in **Settings, People**, or lets a whole email
domain join on its own. There are four roles: viewer, contributor, editor and
admin. See [Host Studio for your team](/docs/studio-hosting#2-let-people-sign-in).
The roles name scopes, which is also what bearer tokens carry:
| Scope | Allows |
| --- | --- |
| `studio:read` | Browsing content, the schema, history and pending approvals |
| `studio:write` | Saving drafts, discarding, compiling. Publishing opens a pull request |
| `studio:publish` | Committing on your own machine, and publishing straight to the production branch |
| `approvals:decide` | Approving and denying |
| `studio:people` | Inviting people and changing their roles (admins) |
| `studio:settings` | Agent tokens, the site's address and deploy hooks (admins) |
## Agents
**Developer, Agents** gives an agent its own token, for the MCP endpoint and
the Studio API. Name the token after the agent and pick how far it may go:
| Access | The agent can |
| --- | --- |
| Read | Read content, never change it |
| Write drafts | Write drafts, which a person publishes |
| Publish | Write and publish |
The token is shown once, so copy it then. Studio keeps only its hash, so a lost
token is revoked and replaced, never shown again. Revoking takes effect on the
agent's next request. No token can approve: destructive calls still wait in the
inbox for a person. Only admins see this page.
Tokens work at `/api/mcp` wherever the site runs `graft serve`. `graft studio`
on your own machine has no MCP endpoint, so tokens made there are for the
hosted site on the same database. An agent on your machine needs no token: it
starts `npx graft mcp` itself.
## Site and deploy hooks
**Settings, Site** holds the site's name and address. Studio shows them, and
agents can read them.
**Settings, Deploy hooks** lists URLs called with a `POST` after every publish:
a host's build hook, so a static site rebuilds, or the site's own revalidate
route, so cached pages refresh. A publish is a commit Studio makes to your
production branch on GitHub, or a publish of content kept in the database. A
commit on your own machine reaches no site, so it calls no hook: your host
builds when you push it. Add a secret if the URL checks one; it is sent
as `Authorization: Bearer `. Each hook shows what it answered last, and
**Send a test** calls it now. A hook that fails never undoes the publish.
The body names what went live:
```json title="Deploy hook body"
{ "event": "publish", "branch": "main", "paths": ["posts/hello-world.mdx"], "at": "2026-10-12T09:00:00.000Z" }
```
Hooks need https, or http to the same machine. They are called when content is
in the database, and when an agent's `publish_drafts` commits to your
production branch on GitHub. A pull request calls none until it is merged,
when your host builds from the commit.
## Developer views
Under **Developer**: index health (each document's state against the compiled
index), the schema, the compile history with restore, branches, agents, and
settings. The index states are explained in [How Graft works](/docs/the-model).
## Do it without Studio
| In Studio | From the CLI or Git | Over MCP |
| --- | --- | --- |
| Find content | `graft content list`, `graft content search` | `list_content`, `search_content` |
| Save an entry | Edit the file, then `graft compile`; or `graft content write` | `write_content` |
| Delete an entry | Delete the file, or `graft content delete` | `delete_content` |
| Publish, locally | `git commit` | |
| Publish, hosted | Merge the draft branch (all of its changes at once) | `publish_drafts` |
| Discard a draft | `git restore` | `discard_drafts` |
| See an entry's history | `git log -- ` | |
| Restore an old version | `git checkout -- ` | `write_content` with the old bytes |
| See past compiles | `graft compilations` | `list_compilations` |
| Approve or deny | `graft approve` / `graft deny` | `decide_approval` |
The UI itself uses the [Studio API](/docs/studio-api), which you can call
directly.
## Next steps
- [Host Studio for your team](/docs/studio-hosting) puts Studio online with sign-in.
- [Schema](/docs/schema) declares the fields Studio builds its forms from.
- [MDX safety](/docs/mdx-safety) explains what a save refuses.
---
# MDX safety
> MDX can run code. Graft refuses the parts that run by default, so anyone who can write content cannot run code on your server.
*Let writers and agents edit content without letting them run code.*
MDX is Markdown that can include components. It can also run code. A
`{expression}` in an MDX file runs JavaScript when the page renders, and an
`import` can load any module. That code runs on your server, with
access to `process.env`, `fetch`, and everything else your app can reach.
When you are the only author and every change goes through review in Git, that
power is useful. It becomes a risk as soon as someone else can write content:
a writer using [Studio](/docs/studio), or an agent writing over MCP. Without a
check, "can edit a page" would mean "can run code on your server".
So, by default, Graft checks MDX on every path it writes or compiles:
`write_content`, Studio saves, and `graft compile`. It refuses anything that
can run code. A body written straight into the database skips the write-time
check. In Next.js, `MdxBody` checks again when it renders, so even that body is
refused there. Plain Markdown and components with plain text attributes still
work. Setting `mdxTrust = "full"` (or `trust="full"` on `MdxBody`) turns the
check off for that path. This page lists what is refused, where the check runs,
and how to allow full MDX for content you write yourself.
## What is protected by default
`mdxTrust` defaults to `"restricted"`, so you get this without configuring
anything.
`graft.config.ts`:
```ts
export const mdxTrust = "restricted"; // the default
```
Under `"restricted"`, a body may be prose, Markdown, GFM, and components with
literal string attributes. Everything that evaluates is refused.
## What is refused
| Construct | Example |
| --- | --- |
| A `{…}` expression | `{process.env.SECRET}` |
| An `import` or `export` | `import x from "y"` |
| An attribute whose value is an expression | `` |
| A spread attribute | `` |
| An element that can load or run code | `` contains no
expression at all, and MDX renders an unknown lowercase tag straight through to
HTML. An `onerror` with a plain string value still runs in a browser, because
React writes an unrecognized attribute into the markup as-is.
## Frontmatter is read as YAML or JSON
The block between the opening and closing `---` is data, and Graft reads it as
YAML, or as JSON when it opens with `---json`. A block that names any other
language, such as `---js`, is refused with `INPUT_VALIDATION_FAILED` before any
of it is read. A JavaScript block would run as code on the machine reading the
file: the Studio server for a contributor's save, the MCP server for an agent's
write, or CI for a pull request.
This holds whatever `mdxTrust` is set to. Graft writes YAML frontmatter itself,
so the fix is to rewrite the block as YAML.
## Where Graft checks
Three places, and they are checking different things.
| Surface | Why it checks |
| --- | --- |
| `graft compile` | Content already in git, so a bad document fails the build rather than a request |
| MCP `write_content` | Content arriving from someone who is not the operator, refused before it is stored |
| Studio saves | Same |
| `MdxBody` at render, and `prepareMdx` | Content can also arrive by a direct database write with the runtime credential, which no write-side guard sees |
The compile check is what makes the failure a build-time one. Without it,
`MdxBody` refusing at render would mean an unchecked tree fails per-request in
production instead.
> **Next.js only.**
> The render-time row is `MdxBody`, which lives in `@usegraft/sdk-next` because
> it evaluates MDX in a Server Component. **No other adapter has a render-time
> check.** Astro, SvelteKit, TanStack Start and React Router hand you the
> authored source and you render it with your own pipeline, which is where that
> responsibility then sits. This documentation site is one of them.
>
> So on those stacks the write-side guards and `graft compile` are the whole
> defence, and the case the render check exists for (content inserted straight
> into the index with the runtime credential, which no write-side guard sees)
> is one your own renderer has to answer. `assertSafeMdx` from
> `@usegraft/mdx-safety` is exported for exactly that.
### Compile reports all of them
Refusals are collected rather than thrown per file. Within one body, every
executable construct is listed; across the tree, every offending document is.
An author fixing one at a time learns the rule slowly and resents it.
One offender still fails the whole compile, so nothing is projected until the
tree is clean. This is a tree-wide gate, not a per-document one.
### The errors
Both are `INPUT_VALIDATION_FAILED`. They differ in what they can name.
Compile names the documents, up to ten of them, then counts the rest:
```text
Executable MDX in 3 document(s): docs/pricing.mdx ({price}),
pages/home.mdx (` and `` contain no `{}`
expression, so the expression checks never saw them. The module now documents
that it is not a general HTML sanitiser.
- **`createGraftMcp` fails closed when `actor` is set without `connectionActor`.**
That combination silently disabled every MCP write-tool scope check, and it
shipped in one of our own example scripts.
`cli`, `contracts`, `core`, `mcp`, `mdx-safety` · [301c817](https://github.com/AndersonDesign1/graft/commit/301c817)
- An approval is filed pending by construction.
Migration `0009_pending_by_construction` adds a `CHECK` on `approvals.status`
and a `BEFORE INSERT` trigger that refuses any insert naming a status other
than `'pending'`, or carrying a decision in `decided_by`, `decided_at`,
`decided_role` or `decided_by_kind`.
The column-scoped INSERT grant already stopped a hardened runtime role from
filing an approved approval. That is a grant list, and a grant list is a thing
someone edits. This puts the same rule in the table, so it holds for every role
including the owner, and no future change to `runtimeRoleGrantsSql` can quietly
reopen it.
The trigger raises rather than coercing the row to `'pending'`. A caller trying
to file a decision has a bug or is an attacker, and silently rewriting the row
would hide both.
Deciding is an `UPDATE` and is untouched.
**Requires a migration.** Run `graft db migrate` (or
`node packages/db/scripts/migrate.mjs`) before serving on an existing database.
`db` · [02690dd](https://github.com/AndersonDesign1/graft/commit/02690dd)
## 0.1.1
Released August 25, 2026.
### Fixes
- `graft --version` reported `0.0.0` instead of the released version.
The version was a hardcoded constant that changesets never touched, and the test
asserted `toContain("0.0.0")` — so it passed _because_ of the bug. The version is
now read from the manifest at runtime, and the test asserts against that value
rather than a literal.
`cli` · [6737b5b](https://github.com/AndersonDesign1/graft/commit/6737b5b)
## 0.1.0
Released August 25, 2026.
### Features
- Initial public release of the Graft packages: contracts, core, db, assets,
compiler, content-migrations, auth, sdk-core, sdk-next, sdk-astro,
sdk-sveltekit, cli, mcp, registry, studio, tokens. All packages version
together (fixed group) while pre-1.0.
`core` · [8d8eda0](https://github.com/AndersonDesign1/graft/commit/8d8eda0)