MADORIMADORI

GraphQL API

Madori auto-generates a GraphQL schema from your blueprints and definitions. Every collection, global, taxonomy, and navigation becomes queryable without writing any schema code. The API updates automatically whenever you add or modify blueprints.

In development, a GraphiQL interface is available at the endpoint URL for exploring and testing queries interactively.


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 {Type}Filter Filter fields for list queries

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 String (serialized JSON)

List Query Arguments

Argument Type Default Description
filter {Type}Filter Key-value object matching field values
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
terms(taxonomy: String!) taxonomy [Term] Get taxonomy terms
navigation(handle: String!) handle Navigation Get a navigation tree
navigations none [Navigation] List all navigations

Usage Examples

Single Entry Query

{
  blog(slug: "hello-world") {
    title
    content
    createdAt
    featured_image
    tags
  }
}

List with Filtering and Pagination

{
  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 {
      label
      url
      entry
      external
      children {
        label
        url
        entry
        external
        children {
          label
          url
        }
      }
    }
  }
}

Querying Taxonomy Terms

{
  terms(taxonomy: "tags") {
    title
    slug
  }
}

Using with graphql-request

import { gql, request } from 'graphql-request'

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 request('http://localhost:3000/api/graphql', POSTS_QUERY, {
  limit: 10,
  offset: 0,
})

Using with Apollo Client

import { ApolloClient, InMemoryCache, gql } from '@apollo/client'

const client = new ApolloClient({
  uri: 'http://localhost:3000/api/graphql',
  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' },
    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' },
    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 with GraphQL

Pre-render all pages at build time using GraphQL:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const data = await queryGraphQL(`{
    blogs(filter: { status: "published" }) {
      slug
    }
  }`)

  return data.blogs.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>
  )
}

Generate types by running pnpm madori generate. See the CLI reference for full details on code generation.