MADORIMADORI

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.