GraphQL API
Madori builds GraphQL schema from current collection definitions, blueprints, fieldsets, and resolver ports. Collection, global, taxonomy, and navigation fields are exposed when corresponding definitions exist. Schema is rebuilt when endpoint is composed, so definition changes are picked up by subsequent requests.
In development, a GraphiQL interface is available at the endpoint URL for exploring and testing queries interactively.
Collection and auxiliary resolvers are permission-guarded: unauthenticated GraphQL requests are denied. Control Panel sessions authenticate with the madori_session cookie; external callers can send a valid session token as Authorization: Bearer <token>. For anonymous browser rendering, use @madori/sdk/hooks/client, which reads published entries through the public content endpoint.
Configuration Reference
Endpoint Configuration
Configure GraphQL behaviour in madori.config.ts:
| Option | Type | Default | Description |
|---|---|---|---|
graphql.enabled |
boolean |
true |
Enable or disable the GraphQL API |
graphql.path |
string |
/api/graphql |
URL path for the GraphQL endpoint |
graphql.introspection |
boolean |
true in dev, false in prod |
Allow schema introspection queries |
// madori.config.ts
graphql: {
enabled: true,
path: '/api/graphql',
introspection: process.env.NODE_ENV !== 'production',
}
Schema Generation Rules
For each collection with a blueprint, Madori generates:
| Generated Item | Naming | Description |
|---|---|---|
| Type | PascalCase of handle | Type with all entry + blueprint fields |
| Singular query | camelCase of handle | Returns a single entry by slug |
| Plural query | camelCase plural of handle | Returns a filtered list |
| Filter input | {BlueprintType}FilterInput |
Optional filter fields for list queries; name follows referenced blueprint |
Standard Entry Fields
Every collection type includes these built-in fields:
| Field | GraphQL Type | Description |
|---|---|---|
title |
String |
Entry title |
slug |
String |
URL slug identifier |
status |
String |
published or draft |
author |
String |
Author identifier |
content |
String |
Markdown body content |
createdAt |
String |
ISO 8601 timestamp |
updatedAt |
String |
ISO 8601 timestamp |
Blueprint Field Type Mapping
| Blueprint Type | GraphQL Type |
|---|---|
text, slug, markdown, tiptap, select, date, asset (single), yaml, code |
String |
number |
Float (or Int with options.integer: true) |
toggle |
Boolean |
multiselect, entries, taxonomy, asset (multiple) |
[String] |
replicator, blocks, grid |
Structured list when configured sets resolve; otherwise JSON |
List Query Arguments
| Argument | Type | Default | Description |
|---|---|---|---|
filter |
{BlueprintType}FilterInput |
— | Key-value object matching field values; type name follows referenced blueprint |
limit |
Int |
all | Maximum entries to return |
offset |
Int |
0 |
Skip N entries (for pagination) |
sort |
String |
— | Format: "fieldName:direction" (e.g. "createdAt:desc") |
Additional Queries
| Query | Arguments | Returns | Description |
|---|---|---|---|
global(handle: String!) |
handle | Global |
Get a global's data |
globals |
none | [Global] |
List all globals |
taxonomy(handle: String!) |
handle | Taxonomy |
Get taxonomy definition |
taxonomies |
none | [Taxonomy] |
List all taxonomy definitions |
terms(taxonomy: String!) |
taxonomy | [Term] |
Get taxonomy terms |
navigation(handle: String!) |
handle | Navigation |
Get a navigation tree |
navigations |
none | [Navigation] |
List all navigations |
SEO Queries and Mutations
When seo.enabled is true, the schema adds permission-guarded SEO operations. SEO reads require view on seo; previews expose provenance only to authorized callers. Redirect writes require edit on seo-redirects and deletes require delete on seo-redirects; report reads require view on seo-reports.
| Operation | Arguments | Purpose |
|---|---|---|
seoSite |
site: String! |
Read site SEO defaults and revision |
seoSection |
section: String!, handle: String! |
Read collection/taxonomy defaults and revision |
seoResolved |
site, collection, slug |
Read published resolved SEO output |
seoPreview |
site, collection, slug |
Read authenticated preview output plus provenance |
seoResolvedTerm |
site, taxonomy, slug |
Read published resolved SEO for a taxonomy term |
seoPreviewTerm |
site, taxonomy, slug |
Read authenticated term preview plus provenance |
seoReport |
optional id, optional site |
Read latest or selected persisted audit report, optionally site-filtered |
seoRedirect |
id: String! |
Read one redirect |
seoRedirects |
optional site |
List redirects, optionally scoped to site |
seoSaveSite |
document, optional expectedRevision |
Save site defaults with optimistic concurrency |
seoSaveSection |
document, optional expectedRevision |
Save section defaults with optimistic concurrency |
seoSaveRedirect |
redirect, optional expectedRevision |
Save validated redirect |
seoDeleteRedirect |
id, optional expectedRevision |
Delete redirect |
Example resolved query:
query SeoForPage {
seoResolved(site: "default", collection: "pages", slug: "about") {
excluded
title
description
canonical
indexing
following
socialImage
jsonLdEnabled
jsonLdType
alternates { locale url }
}
}
SEO GraphQL never returns server filesystem paths or operational storage details. If seo.enabled is false, SEO fields are not registered. Feature-specific switches (metadata, structuredData, reports, and redirects) further limit their corresponding outputs and operations.
Custom JSON-LD is available through the jsonLd.custom SeoJSON scalar. Supply JSON-LD containing an @type through GraphQL variables; the same depth, key-count, and size limits used by REST and file storage are applied before persistence.
Usage Examples
Single Entry Query
{
blog(slug: "hello-world") {
title
content
createdAt
featured_image
tags
}
}
List with Filtering and Pagination
List field is normally pluralised (blog → blogs). A handle already ending in s uses a List suffix for its list field (pages → pagesList) so singular and list operations remain distinct.
{
blogs(
filter: { status: "published" }
limit: 10
offset: 0
sort: "createdAt:desc"
) {
title
slug
createdAt
featured_image
}
}
Multiple Filters
{
blogs(
filter: { status: "published", author: "admin" }
limit: 5
sort: "createdAt:desc"
) {
title
slug
}
}
Querying Globals
{
global(handle: "site-settings") {
data
}
}
Querying Navigation Trees
{
navigation(handle: "main") {
handle
items
}
}
Querying Taxonomy Terms
{
taxonomies {
handle
title
blueprint
}
terms(taxonomy: "tags") {
title
slug
taxonomy
description
data
}
}
Using with graphql-request (optional)
The following is an illustration for projects that install graphql-request; it is not a Madori dependency.
import { gql, GraphQLClient } from 'graphql-request'
const client = new GraphQLClient('http://localhost:3000/api/graphql', {
headers: { Authorization: `Bearer ${process.env.MADORI_SESSION_TOKEN}` },
})
const POSTS_QUERY = gql`
query GetPosts($limit: Int, $offset: Int) {
blogs(
filter: { status: "published" }
limit: $limit
offset: $offset
sort: "createdAt:desc"
) {
title
slug
content
createdAt
featured_image
}
}
`
const data = await client.request(POSTS_QUERY, {
limit: 10,
offset: 0,
})
Using with Apollo Client (optional)
The following is an illustration for projects that install @apollo/client; it is not a Madori dependency.
import { ApolloClient, InMemoryCache, gql } from '@apollo/client'
const client = new ApolloClient({
uri: 'http://localhost:3000/api/graphql',
credentials: 'same-origin', // sends the signed-in madori_session cookie
cache: new InMemoryCache(),
})
const { data } = await client.query({
query: gql`
{
blogs(filter: { status: "published" }, limit: 10, sort: "createdAt:desc") {
title
slug
createdAt
}
}
`,
})
Using with fetch (No Library)
async function queryGraphQL(query: string, variables?: Record<string, unknown>) {
const response = await fetch('http://localhost:3000/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.MADORI_SESSION_TOKEN}`,
},
body: JSON.stringify({ query, variables }),
})
const json = await response.json()
if (json.errors) throw new Error(json.errors[0].message)
return json.data
}
const data = await queryGraphQL(`
{
blogs(limit: 5, sort: "createdAt:desc") {
title
slug
}
}
`)
Next.js Server Component Integration
async function getPublishedPosts() {
const response = await fetch(`${process.env.SITE_URL}/api/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.MADORI_SESSION_TOKEN}`,
},
body: JSON.stringify({
query: `{
blogs(filter: { status: "published" }, sort: "createdAt:desc") {
title
slug
createdAt
featured_image
}
}`,
}),
next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
})
const json = await response.json()
return json.data.blogs
}
export default async function BlogList() {
const posts = await getPublishedPosts()
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
)
}
Common Patterns
Pagination
Implement offset-based pagination using limit and offset:
# Page 1 (items 1-10)
{ blogs(limit: 10, offset: 0) { title slug } }
# Page 2 (items 11-20)
{ blogs(limit: 10, offset: 10) { title slug } }
# Page 3 (items 21-30)
{ blogs(limit: 10, offset: 20) { title slug } }
Sort Patterns
The sort argument uses "field:direction" format:
# Newest first
{ blogs(sort: "createdAt:desc") { title } }
# Alphabetical
{ blogs(sort: "title:asc") { title } }
# By update date
{ blogs(sort: "updatedAt:desc") { title } }
Combining Queries
Request data from multiple sources in a single query:
{
siteSettings: global(handle: "site-settings") {
data
}
mainNav: navigation(handle: "main") {
items {
label
url
children { label url }
}
}
recentPosts: blogs(limit: 3, sort: "createdAt:desc") {
title
slug
}
}
Draft Preview
Query draft entries for preview functionality (requires authentication):
{
blog(slug: "upcoming-post") {
title
content
status
}
}
Disabling Introspection in Production
Prevent schema exposure in production by setting introspection to false:
// madori.config.ts
graphql: {
enabled: true,
path: '/api/graphql',
introspection: false,
}
This disables the __schema and __type queries while leaving all other queries functional.
Static Site Generation
Pre-render pages with the server SDK, which reads local flat files without exposing an authentication token:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const client = madoriClient<Collections>()
const posts = await client.listEntries('blog', { status: 'published' })
return posts.map((post) => ({ slug: post.slug }))
}
Using the Typed SDK
For type-safe content queries without writing GraphQL manually, use the @madori/sdk package.
Server Components:
import { madoriClient } from '@madori/sdk/hooks/server'
import type { Collections } from '@/.madori/generated'
const client = madoriClient<Collections>()
export default async function BlogList() {
const posts = await client.listEntries('blog', {
sort: '-createdAt',
status: 'published',
limit: 10,
})
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>{post.title}</li>
))}
</ul>
)
}
With Next.js Cache Tags (for on-demand revalidation):
import { madoriClient, taggedListEntries } from '@madori/sdk/hooks/server'
import type { Collections } from '@/.madori/generated'
const client = madoriClient<Collections>()
const listEntries = taggedListEntries(client)
// Entries are cached with tag 'madori:collection:blog'
// Revalidate with: revalidateTag('madori:collection:blog')
const posts = await listEntries('blog', { status: 'published' })
Client Components:
'use client'
import { useMadoriEntries } from '@madori/sdk/hooks/client'
export function RecentPosts() {
const { data: posts, isLoading, error } = useMadoriEntries('blog', {
limit: 5,
sort: '-createdAt',
})
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error loading posts</p>
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>{post.title}</li>
))}
</ul>
)
}
Generated projects do not bundle a CLI. Manage content with control panel, or run CLI tooling from Madori source checkout.