Headless Mode
Use Contento as a headless CMS to power any frontend framework.
What is a headless CMS?
A headless CMS decouples content management from content presentation. Contento stores and manages your content through its admin interface and exposes it via a REST API. Your frontend — whether it is built with Next.js, Nuxt, Astro, 11ty, a mobile app, or any other technology — fetches content through the API and renders it however you want.
Benefits of the headless approach:
- Framework freedom — Use React, Vue, Svelte, plain HTML, or anything else
- Multi-channel delivery — Same content on web, mobile, digital signage, etc.
- Performance — Build static sites with pre-rendered pages for blazing speed
- Security — The admin panel is separate from the public site
- Scalability — Scale the frontend independently of the CMS
Why Contento as a headless CMS?
- No vendor lock-in — Self-hosted, open source, your data stays with you
- Clean API — Consistent JSON envelope with pagination, search, and filtering
- Content Delivery API — Public endpoints with response caching, no auth needed for reads
- Full Markdown + HTML — Posts include both
bodyMarkdownandbodyHtml - Table of contents — Auto-generated heading hierarchy for long-form content
- Search built-in — Full-text search with highlights and autocomplete, no external service needed
Authentication
The Content Delivery API (/api/v1/content/*) requires no authentication. These endpoints are designed
for public consumption and include response caching headers.
If you need to manage content programmatically (create posts, moderate comments, upload media), you need a bearer token from the Authentication API:
# 1. Get a token TOKEN=$(curl -s -X POST https://your-site.com/api/v1/auth/token \ -H "Content-Type: application/json" \ -d '{"email":"admin@contento.local","password":"Admin1234"}' \ | jq -r '.data.token') # 2. Use the token curl -H "Authorization: Bearer $TOKEN" \ https://your-site.com/api/v1/posts
/api/v1/auth/refresh
endpoint to get a new token before the current one expires.
Fetching content
Site configuration
Start by fetching the site metadata, active theme, and navigation structure. This gives you everything needed to render a shell/layout:
const res = await fetch('https://your-site.com/api/v1/content/site'); const { data } = await res.json(); console.log(data.site.name); // "My Blog" console.log(data.theme.cssVariables); // CSS variables JSON console.log(data.navigation); // Category tree for nav
Navigation menus
Fetch the navigation menu for a specific location. The response includes a nested tree of items with resolved URLs, ready for rendering:
// Fetch the header navigation menu const menuRes = await fetch( 'https://your-site.com/api/v1/content/menu/header' ); const { data: menuData } = await menuRes.json(); // menuData.menu = { id, name, slug, location } // menuData.items = nested tree: // [{ id, label, url, target, children: [...] }] // Render a nav bar function renderNav(items) { return items.map(item => { if (item.children.length > 0) { // Render as dropdown return `<div class="dropdown"> <button>${item.label}</button> <div>${renderNav(item.children).join('')}</div> </div>`; } return `<a href="${item.url}" target="${item.target}">${item.label}</a>`; }); } // Also available: footer menu const footerRes = await fetch( 'https://your-site.com/api/v1/content/menu/footer' );
Listing posts
// Get the latest 10 posts const res = await fetch( 'https://your-site.com/api/v1/content/posts?pageSize=10' ); const { data, meta } = await res.json(); // data = array of post summaries // meta = { page, pageSize, totalCount, totalPages } // Filter by category const techPosts = await fetch( 'https://your-site.com/api/v1/content/posts?category=technology' ); // Filter by tag const tutorialPosts = await fetch( 'https://your-site.com/api/v1/content/posts?tag=tutorial' ); // Get only featured posts const featuredPosts = await fetch( 'https://your-site.com/api/v1/content/posts?featured=true' );
Reading a single post
const res = await fetch( 'https://your-site.com/api/v1/content/posts/getting-started-with-contento' ); const { data } = await res.json(); // Render the HTML content document.getElementById('content').innerHTML = data.bodyHtml; // Or process the raw markdown yourself const markdown = data.bodyMarkdown; // Use the auto-generated table of contents data.tableOfContents.forEach(heading => { console.log(`${' '.repeat(heading.level - 1)}${heading.text}`); });
Building a Next.js frontend
Here is a complete example of a Next.js App Router integration with Contento.
Create a Contento API client
// lib/contento.ts const CONTENTO_URL = process.env.CONTENTO_API_URL || 'http://localhost:5000'; export async function fetchContento(path: string) { const res = await fetch(`${CONTENTO_URL}/api/v1/content${path}`, { next: { revalidate: 60 }, // ISR: revalidate every 60 seconds }); if (!res.ok) throw new Error(`Contento API error: ${res.status}`); return res.json(); } export async function getSiteConfig() { const { data } = await fetchContento('/site'); return data; } export async function getPosts(page = 1, pageSize = 10) { return fetchContento(`/posts?page=${page}&pageSize=${pageSize}`); } export async function getPost(slug: string) { const { data } = await fetchContento(`/posts/${slug}`); return data; } export async function searchPosts(query: string) { return fetchContento(`/search?q=${encodeURIComponent(query)}`); }
Blog listing page
// app/blog/page.tsx import { getPosts } from '@/lib/contento'; import Link from 'next/link'; export default async function BlogPage() { const { data: posts, meta } = await getPosts(); return ( <main> <h1>Blog</h1> {posts.map((post) => ( <article key={post.id}> <Link href={`/blog/${post.slug}`}> <h2>{post.title}</h2> </Link> <p>{post.excerpt}</p> <time>{new Date(post.publishedAt).toLocaleDateString()}</time> <span>{post.readingTimeMinutes} min read</span> </article> ))} <p>Page {meta.page} of {meta.totalPages}</p> </main> ); }
Single post page
// app/blog/[slug]/page.tsx import { getPost, getPosts } from '@/lib/contento'; import { notFound } from 'next/navigation'; export async function generateStaticParams() { const { data: posts } = await getPosts(1, 100); return posts.map((post) => ({ slug: post.slug })); } export default async function PostPage({ params }) { try { const post = await getPost(params.slug); return ( <article> <h1>{post.title}</h1> {post.subtitle && <p>{post.subtitle}</p>} {/* Table of Contents */} <nav> <h2>Contents</h2> <ul> {post.tableOfContents.map((h) => ( <li key={h.id} style={{ marginLeft: (h.level - 1) * 16 }}> <a href={`#${h.id}`}>{h.text}</a> </li> ))} </ul> </nav> {/* Rendered HTML content */} <div dangerouslySetInnerHTML={{ __html: post.bodyHtml }} /> </article> ); } catch { notFound(); } }
Building a static site with 11ty
Eleventy (11ty) works perfectly with Contento for generating static sites. Add a data file that fetches from the API:
// _data/posts.js module.exports = async function() { const res = await fetch( 'https://your-site.com/api/v1/content/posts?pageSize=100' ); const { data } = await res.json(); return data; };
// _data/site.js module.exports = async function() { const res = await fetch( 'https://your-site.com/api/v1/content/site' ); const { data } = await res.json(); return data; };
Then use the data in your templates:
<!-- blog.njk --> --- pagination: data: posts size: 10 alias: paginatedPosts --- <h1>{{ site.site.name }}</h1> {% for post in paginatedPosts %} <article> <h2><a href="/blog/{{ post.slug }}/">{{ post.title }}</a></h2> <p>{{ post.excerpt }}</p> <time>{{ post.publishedAt | date }}</time> </article> {% endfor %}
Handling comments
Comments use a simple submission endpoint. Here is a vanilla JavaScript comment form:
async function submitComment(slug, formData) { const res = await fetch( `https://your-site.com/api/v1/content/posts/${slug}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ authorName: formData.get('name'), authorEmail: formData.get('email'), body: formData.get('comment'), parentId: null, website: '', // honeypot - always send empty }), } ); const { data } = await res.json(); // data.status === "pending" - comment awaits moderation }
website field in your form that is
invisible to users (via CSS display: none) but visible to bots. Always send an empty string.
Search integration
Contento provides full-text search and autocomplete out of the box:
// Search with highlights async function search(query) { const res = await fetch( `https://your-site.com/api/v1/content/search?q=${encodeURIComponent(query)}` ); const { data, meta } = await res.json(); // Each result has highlightedTitle and highlightedExcerpt // with <mark> tags around matching terms data.forEach(result => { console.log(result.highlightedTitle); // "Getting Started with <mark>Contento</mark>" }); } // Autocomplete suggestions (for search-as-you-type) async function getSuggestions(query) { const res = await fetch( `https://your-site.com/api/v1/content/search/suggestions?q=${encodeURIComponent(query)}&limit=5` ); const { data } = await res.json(); return data; // ["contento cms", "contento api", ...] }
Caching strategies
The Content Delivery API includes Cache-Control headers by default:
| Endpoint | Cache Duration |
|---|---|
/content/site | 5 minutes |
/content/posts | 1 minute |
/content/posts/{slug} | 1 minute |
/content/posts/{slug}/comments | 30 seconds |
/content/categories | 5 minutes |
/content/themes | 5 minutes |
/content/layouts | 5 minutes |
/content/media | 1 minute |
For static site generators, you can also implement your own caching layer:
- ISR (Incremental Static Regeneration) — Next.js
revalidateoption. Re-fetches content at a set interval. - CDN caching — Put a CDN (Cloudflare, Vercel Edge) in front of the API.
- Build-time caching — Cache API responses to disk during
npm run buildfor 11ty/Astro. - Redis optional — If Redis is configured, Contento caches database queries server-side as well.
CORS configuration
When calling the API from a browser on a different domain, CORS headers must be configured.
Set the Contento__AllowedOrigins environment variable:
# Allow specific origins Contento__AllowedOrigins="https://mysite.com,https://staging.mysite.com" # Allow all origins (development only) Contento__AllowedOrigins="*"
*.
Wildcard CORS allows any website to make API requests on behalf of your users.
Webhook support
Webhook support for real-time content change notifications is planned for a future release. When available, you will be able to configure webhook URLs that receive POST requests when:
- A post is published or unpublished
- A comment is approved
- A theme is activated
- Media is uploaded or deleted
In the meantime, you can implement polling or use the cache TTLs listed above for near-real-time content updates.
Membership Content Gating
When using Contento in headless mode, you can implement content gating based on membership tiers. The Content Delivery API returns posts with their access level, and you can check membership status via the Membership API.
Checking membership status
// Check if a reader has an active membership const status = await fetch( `/api/v1/membership/status?email=${email}` ).then(r => r.json()); if (status.data.active) { // Show full post content } else { // Show paywall with link to /membership }
Starting a checkout
// Create a Stripe checkout session const checkout = await fetch('/api/v1/membership/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'reader@example.com', priceId: 'price_1234567890' }) }).then(r => r.json()); // Redirect to Stripe Checkout window.location.href = checkout.data.url;
Newsletter Integration
The Newsletter API provides a public subscribe endpoint that you can integrate into any frontend:
// Subscribe a reader to the newsletter await fetch('/api/v1/newsletter/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'reader@example.com' }) });
The subscribe endpoint is public (no authentication required), making it easy to add newsletter signup forms to any frontend application.