Nitro

Read Graft content in Nitro server routes.

Frameworks6 min read

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/db
npm i @usegraft/sdk-nitro @usegraft/db
bun add @usegraft/sdk-nitro @usegraft/db

Create 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/.

server/utils/graft.ts
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:

server/api/pages/[slug].get.ts
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:

app/pages/[slug].vue
<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:

server/utils/actor.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({
  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.

server/api/fn/[name].ts
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 })));
server/api/mcp.ts
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 Postgres

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. 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