Headless CMS -- Netlify CMS, Decap CMS, Strapi & Tina CMS
In this tutorial, you'll learn about Headless CMS. We cover key concepts, practical examples, and best practices.
A headless CMS decouples content management from presentation, allowing editors to manage content in a friendly UI while developers build the frontend with any static site generator for maximum flexibility and performance.
What You'll Learn
Why It Matters
Traditional CMS platforms like WordPress couple content management with presentation, making it difficult to adopt modern static site workflows. A headless CMS separates content storage and editing from the frontend rendering layer. Editors get a clean UI for writing and managing content, while developers use their preferred static site generator (Hugo, Next.js, or Astro) to build fast, secure static sites. This separation improves developer velocity, site performance, and security -- there is no database to query at request time and no admin panel to secure.
Real-World Use
A marketing team of five non-technical editors manages a 200-page corporate site through Decap CMS while developers deploy updates via Git. An e-commerce team uses Strapi as a headless CMS with custom content types for products, categories, and reviews, feeding a Next.js storefront. A documentation team uses Tina CMS for visual inline editing directly on the live preview.
Headless CMS Architecture
flowchart LR A[Editor] --> B[Headless CMS UI] B --> C[Git Repository] B --> D[API Server] C --> E[Static Site Generator] D --> E E --> F[Static HTML] F --> G[CDN] G --> H[User Browser] style B fill:#f90,color:#fff
CMS Comparison Table
| Feature | Decap CMS | Strapi | Tina CMS | Netlify CMS |
|---|---|---|---|---|
| Storage | Git-based (files) | Database (SQLite/PostgreSQL) | Git-based (files) | Git-based (files) |
| Hosting | Any static host | Node.js server | Any static host | Any static host |
| Content types | Config file | Admin UI | Config file | Config file |
| Media handling | Git LFS or external | Upload to server | Git LFS or external | Git LFS or external |
| Authentication | OAuth (GitHub/GitLab) | JWT + roles | GitHub OAuth | OAuth (GitHub/GitLab) |
| Preview | Build preview | Live preview | Visual inline edit | Build preview |
| API | Git-based | REST + GraphQL | Git-based | Git-based |
| Extensibility | Custom widgets | Plugin marketplace | Custom fields | Custom widgets |
| Self-hosted | Yes | Yes | Yes | Yes |
| Learning curve | Low | Medium | Low | Low |
Decap CMS -- Git-Based Simplicity
Decap CMS (formerly Netlify CMS) stores content as Markdown files in your Git repository. It is the simplest headless CMS to integrate with static sites.
Configuration
# static/admin/config.yml -- Decap CMS configuration
backend:
name: git-gateway
branch: main
commit_messages:
create: "Created {{slug}} via Decap CMS"
update: "Updated {{slug}} via Decap CMS"
delete: "Deleted {{slug}} via Decap CMS"
media_folder: "static/images/uploads"
public_folder: "/images/uploads"
collections:
- name: "tutorials"
label: "Tutorials"
folder: "content/static-sites"
create: true
slug: "{{slug}}"
fields:
- { label: "Title", name: "title", widget: "string" }
- { label: "Description", name: "description", widget: "text" }
- { label: "Publish Date", name: "date", widget: "datetime" }
- { label: "Body", name: "body", widget: "markdown" }
- { label: "Tags", name: "tags", widget: "list", default: ["static-sites"] }
- { label: "Weight", name: "weight", widget: "number", value_type: "int" }
Expected behavior: Editors access /admin/ on the live site, see a clean UI listing all tutorials, and can create, edit, or delete content. Each change creates a Git commit with the configured message format. The site rebuilds on each push via the CI/CD pipeline.
Custom Widget Example
// src/widgets/code-editor.js -- Custom widget for code snippets
const CodeEditor = createClass({
render() {
const { value, onChange, field } = this.props;
return h('textarea', {
value,
onChange: e => onChange(e.target.value),
style: {
fontFamily: 'monospace',
minHeight: '200px',
width: '100%',
},
});
},
});
CMS.registerWidget('code-editor', CodeEditor);
Expected behavior: The custom widget registers a monospace textarea for editing code blocks within the CMS. Editors can use it for any field with widget: "code-editor" in the config.
Strapi -- API-Driven CMS
Strapi is a self-hosted headless CMS with a GraphQL and REST API, suitable for complex content relationships and user permissions.
Strapi Content Type Definition
// src/api/tutorial/content-types/tutorial/schema.json
{
"kind": "collectionType",
"collectionName": "tutorials",
"info": {
"singularName": "tutorial",
"pluralName": "tutorials",
"displayName": "Tutorial"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 120
},
"slug": {
"type": "uid",
"targetField": "title"
},
"description": {
"type": "text",
"maxLength": 160
},
"content": {
"type": "richtext",
"required": true
},
"tags": {
"type": "relation",
"relation": "manyToMany",
"target": "api::tag.tag"
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category"
}
}
}
Expected behavior: The content type defines a Tutorial with title, slug, description, rich text body, tags (many-to-many), and category (many-to-one). Strapi generates a REST API at /api/tutorials and a GraphQL endpoint at /<a href="/apis/graphql/">graphql</a> automatically.
Fetching Content in Next.js
// lib/strapi.js -- Fetch content from Strapi API
const API_URL = process.env.STRAPI_API_URL || 'http://localhost:1337';
export async function getTutorials() {
const res = await fetch(`${API_URL}/api/tutorials?populate=tags,category`);
if (!res.ok) throw new Error(`Failed to fetch tutorials: ${res.status}`);
const data = await res.json();
return data.data;
}
export async function getTutorial(slug) {
const res = await fetch(
`${API_URL}/api/tutorials?filters[slug][$eq]=${slug}&populate=tags,category`
);
const data = await res.json();
return data.data[0] || null;
}
Expected behavior: The function fetches all tutorials with populated relations from Strapi's REST API. During the Next.js build, getStaticProps calls this function and generates static pages from the API response.
Tina CMS -- Visual Editing
Tina CMS provides inline visual editing directly on the site, with content stored in Git as Markdown or JSON files.
Tina Schema
// tina/config.ts -- Tina CMS schema
import { defineConfig } from 'tinacms';
export default defineConfig({
branch: process.env.TINA_BRANCH || 'main',
clientId: process.env.TINA_CLIENT_ID,
token: process.env.TINA_TOKEN,
build: {
outputFolder: 'admin',
publicFolder: 'static',
},
schema: {
collections: [
{
name: 'tutorial',
label: 'Tutorials',
path: 'content/static-sites',
format: 'md',
fields: [
{
type: 'string',
name: 'title',
label: 'Title',
isTitle: true,
required: true,
},
{
type: 'string',
name: 'description',
label: 'Description',
ui: {
component: 'textarea',
},
},
{
type: 'datetime',
name: 'date',
label: 'Date',
},
{
type: 'rich-text',
name: 'body',
label: 'Body',
isBody: true,
},
],
},
],
},
});
Expected behavior: Tina CLI generates an admin UI based on this schema. Editors visit /admin/index.html and see their live site with edit buttons overlaid on editable content. Changes are committed to Git as Markdown files in content/static-sites/.
Common Errors
1. Missing OAuth Configuration for Git-Based CMS
Decap CMS and Tina CMS require OAuth app configuration (GitHub, GitLab, or Bitbucket). Without it, authentication fails silently. Configure the OAuth app with the correct callback URL pointing to your deployed site's /admin/ path.
2. Content Type Mismatch Between CMS and SSG
If Strapi stores content as rich text HTML but Hugo expects Markdown, the rendered output breaks. Ensure the CMS output format matches the SSG's expected input format, or add a transformation layer during the build.
3. Media Storage Limits with Git LFS
Git hosting platforms limit LFS storage (GitHub gives 1GB free). For media-heavy sites, configure external storage (Cloudinary, S3, or Uploadcare) instead of Git LFS. Update the CMS media folder configuration accordingly.
4. API Rate Limiting During Build
Fetching all content from Strapi during a build can hit API rate limits for large sites. Implement pagination in your fetch logic, and cache the API response between builds.
// BAD: Single fetch with no pagination
const res = await fetch(`${API_URL}/api/tutorials`);
// GOOD: Paginated fetch that handles large collections
async function fetchAllTutorials() {
let page = 1;
let allTutorials = [];
let hasMore = true;
while (hasMore) {
const res = await fetch(
`${API_URL}/api/tutorials?pagination[page]=${page}&pagination[pageSize]=100`
);
const data = await res.json();
allTutorials = allTutorials.concat(data.data);
hasMore = data.meta.pagination.pageCount > page;
page++;
}
return allTutorials;
}
5. Preview Builds Taking Too Long
Every CMS save triggers a Git push, which triggers a site rebuild. For large sites, preview builds can take minutes. Use incremental builds (Hugo, Next.js ISR) or deploy preview environments to give editors faster feedback.
Practice Questions
1. What is the fundamental difference between Git-based and API-driven headless CMS?
Git-based CMS (Decap, Tina, Netlify) store content as files in a Git repository, making content changes equivalent to code changes. API-driven CMS (Strapi) store content in a database and serve it via REST/GraphQL API.
2. How does Decap CMS push content changes to the static site?
Each content edit creates a Git commit (via the configured backend, e.g., GitHub OAuth). The Git push triggers the CI/CD pipeline, which rebuilds the static site with the new content.
3. What is the advantage of Tina CMS's visual editing over traditional form-based CMS?
Editors see their changes in the context of the live site layout, reducing the cognitive gap between editing and previewing. Inline editing provides immediate visual feedback without switching between an admin panel and a preview tab.
4. Why might you choose Strapi over a Git-based CMS?
When content has complex relationships (many-to-many tags, nested categories), requires role-based access control, or needs to be consumed by multiple frontends simultaneously. Strapi's API-first design handles these scenarios natively.
5. Challenge: Create a Decap CMS collection schema for a recipe site with ingredients (repeatable group) and cooking time (number field).
Define a collection with fields: title, description, image, ingredients (as a list of groups with name, quantity, unit fields), cooking_time, difficulty (select), and body (markdown).
Mini Project: Integrate Decap CMS with Hugo
Add a headless CMS to an existing Hugo site:
- Create
static/admin/config.ymlwith a collection matching your content structure - Set up OAuth with GitHub (create a GitHub OAuth app)
- Add the Decap CMS HTML page at
static/admin/index.html - Configure
content/as the folder for your collections - Push to GitHub and verify editing works at
/admin/
<!-- static/admin/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Content Manager</title>
<script src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script>
</head>
<body>
<script src="https://unpkg.com/netlify-cms@^2.10.0/dist/netlify-cms.js"></script>
</body>
</html>
After deployment, visit https://yoursite.com/admin/, authenticate with GitHub, and edit your tutorial content through the CMS interface. Each save creates a commit and triggers a rebuild.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro