Content API

Serve your content over HTTP, for frontends that cannot read the index directly.

Reference4 min read

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:

app/api/content/v1/[...path]/route.ts
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 set

collections 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:

src/lib/graft.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,
});
OptionWhat it does
endpointThe API's base URL
headersHeaders sent with every request, such as Authorization
fetchYour 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:

RouteRequiredOptionalReturns
/api/content/v1/documentscollectionslug, limit, offset{ "rows": [...] }
/api/content/v1/searchcollection, querylimit{ "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

FieldType
collection, slugstring
dataobject, the checked frontmatter
bodystring, the Markdown or MDX body
sourcePathstring, the file to edit
contentHashstring
branchIdstring
deletedboolean
updatedAtstring, ISO 8601
searchstring 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

StatusCodeWhen
400INPUT_VALIDATION_FAILEDA bad limit or offset, an empty slug, a missing collection or query, or a branch parameter
404COLLECTION_NOT_FOUNDThe collection is not published on this endpoint. The fix lists those that are.
404ROUTE_NOT_FOUNDNothing is served at that path
405METHOD_NOT_ALLOWEDAnything other than GET
500FUNCTION_EXECUTION_FAILEDThe 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