Docs Headless Mode

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 bodyMarkdown and bodyHtml
  • 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
Token lifetime: Tokens expire after 24 hours. Use the /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
}
Tip: For the honeypot to work, include a hidden 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:

EndpointCache Duration
/content/site5 minutes
/content/posts1 minute
/content/posts/{slug}1 minute
/content/posts/{slug}/comments30 seconds
/content/categories5 minutes
/content/themes5 minutes
/content/layouts5 minutes
/content/media1 minute

For static site generators, you can also implement your own caching layer:

  • ISR (Incremental Static Regeneration) — Next.js revalidate option. 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 build for 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="*"
Security: In production, always specify exact origins rather than using *. 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.