mxHeadless
REST API gateway for headless frontends on MODX 3. Resources, objects, OpenAPI, API keys, and OAuth

App Router: server helper, page by URI, Route Handler proxy.
.env.local:
MXHEADLESS_BASE_URL=https://example.com/api/v1
MXHEADLESS_API_KEY=mxh_...Use the key only in Server Components, Route Handlers, or server-only modules.
lib/mxheadless.ts:
import 'server-only'
type Envelope<T> = {
data: T
meta?: Record<string, unknown>
links?: Record<string, string>
}
const baseURL = process.env.MXHEADLESS_BASE_URL!
const apiKey = process.env.MXHEADLESS_API_KEY
export async function mxGet<T>(
path: string,
query?: Record<string, string | number | boolean>,
init?: RequestInit,
): Promise<Envelope<T>> {
const url = new URL(path.replace(/^\//, ''), baseURL.endsWith('/') ? baseURL : baseURL + '/')
if (query) {
for (const [k, v] of Object.entries(query)) {
url.searchParams.set(k, String(v))
}
}
const res = await fetch(url, {
...init,
headers: {
Accept: 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...(init?.headers || {}),
},
next: init?.next ?? { revalidate: 60 },
})
if (!res.ok) {
throw new Error(`mxHeadless ${res.status}: ${await res.text()}`)
}
return res.json() as Promise<Envelope<T>>
}app/[[...slug]]/page.tsx:
import { notFound } from 'next/navigation'
import { mxGet } from '@/lib/mxheadless'
type Props = { params: Promise<{ slug?: string[] }> }
export default async function CmsPage({ params }: Props) {
const { slug } = await params
const uri = slug?.length ? `${slug.join('/')}.html` : 'index.html'
let page
try {
page = await mxGet<Record<string, unknown>>(`/pages/${encodeURIComponent(uri)}`, {
fields: 'id,pagetitle,content,uri',
})
} catch {
notFound()
}
return (
<article>
<h1>{String(page.data.pagetitle ?? '')}</h1>
{/* Sanitize HTML (DOMPurify) before rendering */}
<div>{String(page.data.content ?? '')}</div>
</article>
)
}app/api/news/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { mxGet } from '@/lib/mxheadless'
export async function GET(req: NextRequest) {
const parent = req.nextUrl.searchParams.get('parent') ?? '2'
const body = await mxGet('/resources', {
'filter[published]': 1,
'filter[parent]': parent,
limit: 20,
fields: 'id,pagetitle,uri',
})
return NextResponse.json(body)
}Point an mxHeadless webhook at a Route Handler that verifies X-MxHeadless-Signature and calls revalidatePath / revalidateTag. See Webhooks.