Nuxt
Add Graft to a Nuxt app with one module.
This guide adds Graft to a Nuxt app with @usegraft/sdk-nuxt, a Nuxt module.
Once it is in nuxt.config.ts you get:
- Page composables:
useGraftContent,useGraftListanduseGraftSearch. They read on the server for the first paint, and in the browser when the visitor navigates. useGraft()in server routes: the typed read handle, over the static index or Postgres.<GraftMdx>andprepareMdx, to render a document's MDX body with your own Vue components.- The content API, functions and MCP routes, each turned on by one option.
Each one is auto-imported, so you write no imports from Graft in your pages or server routes.
Before you start, you need a Nuxt 4.6 or newer (or Nuxt 5) app and a
graft.config.ts with compiled content. If you do not have those yet, run
through Getting started first.
Starting from nothing? npm create graft@latest my-site -- --framework nuxt
makes a Nuxt site with this module already set up: a home page, a page for
each collection and each entry, and MDX bodies rendered with <GraftMdx>.
Install the module
pnpm add @usegraft/sdk-nuxtnpm i @usegraft/sdk-nuxtbun add @usegraft/sdk-nuxtThen add it to modules, and turn on the content API, which the page
composables read through:
export default defineNuxtConfig({
modules: ["@usegraft/sdk-nuxt"],
graft: { contentApi: true },
});The module reads graft.config.ts from the Nuxt root. Set graft.config to a
path relative to the root if yours lives somewhere else.
Read content in a page
useGraftContent reads one document by collection and slug. Pass a getter
for the slug, so the read follows the route:
<script setup lang="ts">
const route = useRoute();
const slug = () => String(route.params.slug);
const { data: page, error } = await useGraftContent("pages", slug);
if (error.value) throw error.value;
if (!page.value) throw createError({ statusCode: 404, statusMessage: "No such page" });
</script>
<template>
<main v-if="page">
<h1>{{ page.data.title }}</h1>
</main>
</template>page.data has the type your defineCollection declared, so a renamed field
is a type error in the page rather than undefined at runtime. A failed read,
such as a misconfigured engine, arrives in error, so the page shows that
error instead of a 404.
The read runs on the server for the first paint, and the document travels to the browser in the page payload, so the page hydrates without fetching it again. When the visitor follows a link to another slug, the same code reads the next document over HTTP.
useGraftList and useGraftSearch work the same way. Each returns Nuxt's
AsyncData, so data, status, error and refresh are all there:
<script setup lang="ts">
const { data: pages } = await useGraftList("pages");
const query = ref("");
const { data: hits } = await useGraftSearch("pages", query);
</script>
<template>
<input v-model="query" placeholder="Search" />
<ul>
<li v-for="page in query ? (hits ?? []) : (pages ?? [])" :key="page.slug">
<NuxtLink :to="`/${page.slug}`">{{ page.data.title }}</NuxtLink>
</li>
</ul>
</template>With an empty box the page lists every document. A blank search answers an empty list without a request.
The composables check every document against your collections, as every Graft
client does, so graft.config.ts is part of the browser bundle. Keep it free
of server-only imports, such as a database client.
Read content in a server route
In server/, useGraft() returns the typed read handle. It reads the storage
engine directly, without going through the content API:
import { defineEventHandler, getRouterParam } from "nuxt/server";
export default defineEventHandler(async (event) => {
const graft = await useGraft();
const page = await graft.getContent("pages", getRouterParam(event, "slug") ?? "");
return { title: page?.data.title ?? null };
});Render MDX with Vue components
A document's body is its MDX source as written. Compile it in a server route
with prepareMdx, then render the result in the page with <GraftMdx>:
import { createError, defineEventHandler, getRouterParam } from "nuxt/server";
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 { code: await prepareMdx(page.body) };
});<script setup lang="ts">
const route = useRoute();
const { data: mdx } = await useFetch(() => `/api/mdx/${route.params.slug}`);
</script>
<template>
<GraftMdx v-if="mdx" :code="mdx.code" />
</template>The compiler stays on the server, and <GraftMdx> renders its output on the
server and again when the page hydrates. Pass the components a body uses as
:components="{ Callout }". A component receives the content between its tags
as its default slot. prepareMdx refuses the same constructs the
Vue guide lists, and <GraftMdx>
runs the compiled string with new Function, so a page with a
Content-Security-Policy needs 'unsafe-eval' in script-src. See
MDX safety.
Choose where content is read from
The module reads the engine graft.config.ts names, the same way
graft compile does:
index in graft.config.ts | What the module opens |
|---|---|
"static" | The SQLite file graft compile writes, .graft/index.db |
"postgres", or no index export | Postgres, at NUXT_GRAFT_DATABASE_URL, else DATABASE_URL |
Paths are relative to the directory the server runs in. That is the project
root, in development and when you start the build with
node .output/server/index.mjs. With the static engine, run graft compile
before nuxi build, and deploy .graft/index.db beside the server, or point
NUXT_GRAFT_STATIC_INDEX at it.
Each runtime setting is under runtimeConfig.graft, and each one reads its
environment variable when the server starts:
| Variable | What it sets |
|---|---|
NUXT_GRAFT_DATABASE_URL | The Postgres connection string. Falls back to DATABASE_URL. |
NUXT_GRAFT_STATIC_INDEX | The static index file, instead of the path in graft.config.ts. |
NUXT_GRAFT_DEV_TOKEN | A bearer token the functions and MCP routes accept in development only. |
NUXT_GRAFT_DEV_SCOPES | The dev token's scopes, comma or space separated. Unset, it gets content:write. |
NUXT_GRAFT_TRUSTED_ISSUERS | JWT issuers those routes trust, comma or space separated. |
NUXT_GRAFT_TRUSTED_AUDIENCE | The token audience (aud) those routes accept. Unset, the audience is not checked. |
NUXT_GRAFT_TRUSTED_ACTOR_KIND | agent (the default) or human: how token holders are recorded. |
A misconfigured engine fails the request with CONFIG_INVALID and a fix
that names the setting to change. The next request tries again, so you can fix
the environment without a rebuild.
Serve functions and MCP from your app
These routes write data, so they need the Postgres engine, and three more packages:
npm i @usegraft/core @usegraft/auth @usegraft/mcp
Turn on the routes you want:
export default defineNuxtConfig({
modules: ["@usegraft/sdk-nuxt"],
graft: { contentApi: true, functions: true, mcp: true },
});| Option | Route |
|---|---|
contentApi | GET /api/content/v1/*, the read-only Content API |
functions | POST /api/fn/:name, your typed functions |
mcp | POST /api/mcp, MCP over Streamable HTTP |
Callers of the functions and MCP routes authenticate with a JWT from one of
NUXT_GRAFT_TRUSTED_ISSUERS, or with NUXT_GRAFT_DEV_TOKEN while
NODE_ENV is development. An unset NODE_ENV counts as production. Set
NUXT_GRAFT_TRUSTED_AUDIENCE to the audience your provider puts in tokens for
this app, or a token it signs for another app is accepted too. See
Auth.
Both routes follow approvalPolicy in graft.config.ts, and MCP writes follow
mdxTrust, as graft serve does. Each caller gets 60 calls a minute unless a
function sets its own rateLimit.
With the static engine, both routes answer 503 with CONFIG_INVALID, and a
fix that says to switch to Postgres or turn the route off.
The content API route has no rate limit. During server rendering every page view reads it from the server itself, with no address to tell visitors apart, so one shared limit would throttle the whole site. Put a rate limiter in front of it on a public deploy. See Rate limits.
Supported versions
The module supports Nuxt 4.6 and newer, and Nuxt 5. It uses nuxt/server,
which Nuxt added in 4.6.0, so the same server code runs on both majors. CI
builds a real Nuxt 4 app and a real Nuxt 5 nightly app on every pull request,
type-checks each one, serves it, and drives it in Chromium.
On an older Nuxt 4, or a standalone Nitro app, mount Graft's handlers yourself
with @usegraft/sdk-nitro. See Nitro.
Next steps
- Content API documents the routes the composables read.
- Vue covers
MdxContentand the components a body can use. - Schema defines the collections your reads are typed from.
- Functions & access explains the typed functions
the
functionsroute serves.