Content API
Serve your content over HTTP, for frontends that cannot read the index directly.
Usually your app reads content straight from the index: a file on the static engine, or the database on Postgres. Sometimes it cannot. A browser app has neither, and a frontend deployed separately from your content may not either.
The Content API covers that case. A small HTTP handler serves your content,
and a matching reader on the frontend fetches it. You give the reader to any
Graft SDK as its index, and your code reads content exactly as before:
getContent, listContent, searchContent, fully typed.
It is read-only and serves one branch. This page shows how to serve it, read from it, and keep it private, then lists the endpoints in full.
Serve it
graft serve already serves it at /api/content/v1. To serve it from your own
app, create the handler with the index reader you already have:
import { createContentApiHandler } from "@usegraft/content-api";
import { createDbIndexReader } from "@usegraft/db";
const handler = createContentApiHandler({
collections: ["docs", "pages"], // only these are served
branch: "main",
index: createDbIndexReader(db),
});
export const GET = (request: Request) => handler(request);
export const OPTIONS = GET; // browser preflight, when allowedOrigins is setcollections is the list of what is published. A collection not on it does
not exist as far as the endpoint is concerned. graft serve publishes every
collection in your config, so serve the handler yourself if you need a shorter
list.
Read from it
On the frontend, create a reader for the endpoint and pass it to your SDK as
the index:
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,
});| Option | What it does |
|---|---|
endpoint | The API's base URL |
headers | Headers sent with every request, such as Authorization |
fetch | Your own fetch, for tests, instrumentation, or runtimes without one |
Every document is checked against your schema when it arrives, just as a local read is, so a document that does not match never reaches your code.
Errors keep their meaning over HTTP. A Graft error on the server arrives as the
same Graft error on the client, with its code and fix. Anything else, such
as a proxy returning HTML, becomes FUNCTION_EXECUTION_FAILED with a message
saying what went wrong.
It does not authenticate requests
The handler checks no identity. Every document in every published collection is readable by anyone who can reach the URL. That is deliberate: published content is usually public, and any login scheme the handler guessed at would be wrong for most sites.
So the collections list is your security boundary. If some content must stay
private, do not publish its collection here. If the whole endpoint must be
private, put a proxy in front of it that checks a token, and send that token
with the reader's headers. Do not rely on nobody finding the URL.
The one protection built in is a rate limit.
graft serve sets 60 requests per 60 seconds. A handler you mount yourself has
none unless you pass rateLimit.
Read it from a browser
A browser blocks reads from another origin unless the server allows them, and the handler allows none by default. If your browser app is served from a different origin than the API, list the origins that may read it:
createContentApiHandler({
collections,
branch: "main",
index,
allowedOrigins: ["https://app.example.com"],
});
On graft serve, set GRAFT_CONTENT_ALLOWED_ORIGINS to a comma-separated list,
or to *. Allowing every origin gives nothing away here, since anyone can
already read the endpoint from outside a browser.
Error responses carry the same headers, so a failing browser request shows its
fix instead of an opaque network error.
Endpoints
Two routes, both GET:
| Route | Required | Optional | Returns |
|---|---|---|---|
/api/content/v1/documents | collection | slug, limit, offset | { "rows": [...] } |
/api/content/v1/search | collection, query | limit | { "hits": [{ row, rank, snippet }] } |
curl "https://cms.example.com/api/content/v1/documents?collection=docs&slug=tiers"
curl "https://cms.example.com/api/content/v1/search?collection=docs&query=branching"
limit must be a positive whole number, and values above 500 are treated as
500. offset must be zero or more. Search takes one collection at a time. The
query syntax is the same as Search.
Each row
| Field | Type |
|---|---|
collection, slug | string |
data | object, the checked frontmatter |
body | string, the Markdown or MDX body |
sourcePath | string, the file to edit |
contentHash | string |
branchId | string |
deleted | boolean |
updatedAt | string, ISO 8601 |
search | string or null |
One endpoint serves one branch
A branch query parameter is refused, so no request can switch a production
endpoint onto preview content. For preview reads, serve a second endpoint for
that branch.
Errors
| Status | Code | When |
|---|---|---|
| 400 | INPUT_VALIDATION_FAILED | A bad limit or offset, an empty slug, a missing collection or query, or a branch parameter |
| 404 | COLLECTION_NOT_FOUND | The collection is not published on this endpoint. The fix lists those that are. |
| 404 | ROUTE_NOT_FOUND | Nothing is served at that path |
| 405 | METHOD_NOT_ALLOWED | Anything other than GET |
| 500 | FUNCTION_EXECUTION_FAILED | The endpoint could not read its index |
Limitations
- Read-only. Content is written through files, the CLI, or MCP.
- One branch per endpoint.
- No operational data. User data is only reachable through typed functions.
- No authentication. See above.
Next steps
- Reading content covers the reads this serves.
- Deploy shows where the endpoint runs.
- React reads it from the browser.