Nitro
Read Graft content in Nitro server routes.
This guide adds Graft to a Nitro project with @usegraft/sdk-nitro. You read
content in server routes, fetch it from your pages, and can serve Graft's
functions and MCP endpoint from the same app.
One package covers all of these, because each one runs on Nitro:
- Nuxt 4 runs nitropack 2 on h3 1.
- Nuxt 5 runs Nitro 3 on h3 2.
- Nitro 3 on its own runs on h3 2.
Before you start, you need a Nuxt or Nitro project and a graft.config.ts
with compiled content. If you do not have those yet, run through
Getting started first.
Install
pnpm add @usegraft/sdk-nitro @usegraft/dbnpm i @usegraft/sdk-nitro @usegraft/dbbun add @usegraft/sdk-nitro @usegraft/dbCreate the Graft client
Create the client in server/utils/. Nuxt keeps that folder out of the
browser bundle, and auto-imports what it exports into your server routes.
In a standalone Nitro app, put the same file in your server's utils/.
import { openStaticIndex } from "@usegraft/db";
import { createGraft, type Graft } from "@usegraft/sdk-nitro";
import { collections } from "../../graft.config";
let graft: Promise<Graft<typeof collections>> | undefined;
// Opened on first use. Nuxt 4 compiles server code to ES2019, which has no
// top-level await, so `await openStaticIndex(...)` at the top level fails to build.
export function useGraft(): Promise<Graft<typeof collections>> {
graft ??= openStaticIndex(".graft/index.db")
.then((index) => createGraft({ index, collections }))
.catch((error: unknown) => {
// Forget a failed open, so the next request tries again.
graft = undefined;
throw error;
});
return graft;
}Postgres instead of the static index:
import { createDb } from "@usegraft/db";
import { createGraft } from "@usegraft/sdk-nitro";
import { collections } from "../../graft.config";
export const db = createDb(process.env.DATABASE_URL ?? "").db;
export const useGraft = async () => createGraft({ db, collections });
Read a document
Add a server route that reads the document:
export default defineEventHandler(async (event) => {
const graft = await useGraft();
const page = await graft.getContent("pages", getRouterParam(event, "slug") ?? "");
if (!page) throw createError({ statusCode: 404 });
return page;
});Then fetch it from the page:
<script setup lang="ts">
const route = useRoute();
const { data: page } = await useFetch(`/api/pages/${route.params.slug}`);
</script>
<template>
<h1>{{ page?.data.title }}</h1>
</template>getContent returns the type your defineCollection declared, so a renamed
field is a build error in the server route rather than undefined at runtime.
Serve functions and MCP from your app
This step is optional. It needs the Postgres engine and three more packages:
npm i @usegraft/core @usegraft/auth @usegraft/mcp
Both routes identify callers the same way, so put the resolver in one file:
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({
issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "")
.split(/[,\s]+/)
.filter(Boolean)
.map((issuer) => ({ issuer })),
devTokens: devToken
? { [devToken]: { kind: "human", id: "owner", scopes: ["content:write"] } }
: undefined,
});graftHandler turns a Graft handler into a Nitro event handler. Leave the
method out of the file name, so a GET reaches the handler and gets its 405 with
an Allow header and a fix.
import { createFunctionsHandler } from "@usegraft/core";
import { graftHandler } from "@usegraft/sdk-nitro";
import { functions } from "../../../graft.config";
import { actor } from "../../utils/actor";
import { db } from "../../utils/graft";
export default defineEventHandler(graftHandler(createFunctionsHandler({ db, functions, actor })));import { resolve } from "node:path";
import { createGraftMcpHandler } from "@usegraft/mcp";
import { graftHandler } from "@usegraft/sdk-nitro";
import { collections, functions } from "../../graft.config";
import { actor } from "../utils/actor";
import { db } from "../utils/graft";
// An absolute path. The server runs from the project root, in development and
// with `node .output/server/index.mjs`.
const contentDir = resolve(process.cwd(), "content");
export default defineEventHandler(
graftHandler(createGraftMcpHandler({ contentDir, 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.
Unauthenticated callers get 401. allowAnonymous: true is only for a
loopback local server, never anything reachable from a network.
graftHandler works the same on h3 1 and h3 2, and is typed structurally, so
this package depends on no Nuxt, Nitro or h3 version. It also handles a page
that calls its own route with $fetch during server rendering. Nuxt 4 skips
the network for that call and puts the body somewhere a plain stream reader
would miss.
Each Graft handler refuses a body over its own limit with 413: 1 MiB for
functions, 32 MiB for MCP. On h3 1 under Node, graftHandler streams the body
to the handler instead of buffering it first, and stops a body that passes
32 MiB as the bytes are read. A handler that answers before reading the whole
body gets the connection closed after its response. Pass maxBodyBytes to change that ceiling, such as
graftHandler(handler, { maxBodyBytes: 64 * 1024 * 1024 }). See
Request bodies have a size limit.
Rate limits count the real caller
Graft's rate limits count anonymous callers by the address they connected
from. Most frameworks hand Graft only the request, so every anonymous caller
shares one bucket. Nitro hands over the connection too, so graftHandler
records the socket address, and each caller gets its own bucket. A Nitro host that gives no client address leaves anonymous callers sharing one, as before.
The address comes from the connection, never from a header. A client that
sends its own x-forwarded-for is still counted by where it connected from.
That also means that behind a proxy or load balancer, every caller connects
from the proxy and shares its bucket. Declare how many proxies you run with
trustedProxyHops on the handler that rate-limits: createFunctionsHandler
or createContentApiHandler. graftHandler itself has no such option. See
Rate limits.
Refresh pages when content changes
needs PostgresOn 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. Nitro has no tag-based data cache, so let your CDN cache pages and purge only the ones whose content changed. Send each response with a header that lists the content it read:
import { tagsFor } from "@usegraft/sdk-nitro";
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug") ?? "";
const page = await (await useGraft()).getContent("pages", slug);
setResponseHeader(event, "Cache-Tag", tagsFor("main", "pages", slug).join(","));
return page;
});
Then purge tagsForChanges(branch, changes) at your CDN when content changes.
Writes through the MCP endpoint your app mounts call onContentChange, so pass
it to createGraftMcpHandler and purge there. Writes from graft serve,
graft studio and the CLI call a route instead, set by GRAFT_REVALIDATE_URL
and GRAFT_WEBHOOK_SECRET. The SvelteKit guide
walks through that route. Nothing about it is SvelteKit-specific.
Next steps
- Reading content covers list and search reads.
- SDK reference compares route mounts across frameworks.
- Connecting agents points an agent at your project.