Headless CMS Integration
Integrate WordPress, Sanity, Contentful, or any headless CMS with the Astro content layer for flexible content management
Markdown Source
Overview
The Astro CMS is designed with a flexible Content Layer API that allows you to swap content sources without changing your templates or components. Start with markdown, migrate to a headless CMS later - your site keeps working.
Supported Platforms
Built-In Support
- Markdown/MDX: File-based content (default)
- JSON/YAML: Structured data files
Via Loaders
- WordPress: REST API or GraphQL
- Sanity: Sanity.io integration
- Contentful: Contentful CMS
- Strapi: Open-source headless CMS
- Ghost: Ghost CMS
- Notion: Notion as CMS
- Any REST API: Custom loader
Architecture
Content Layer Pattern
All content goes through the same interface:
// Your templates always use this:
const entries = await getCollection('cms');
// Content source is configured once:
// src/content/config.ts
const cms = defineCollection({
loader: /* markdown | wordpress | sanity | custom */,
schema: cmsSchema
});
Benefits:
- Change CMS without touching templates
- Test different CMSs easily
- Migrate incrementally
- Use multiple sources simultaneously
WordPress Integration
Setup
1. Install WordPress Loader
npm install @astrojs/wordpress
2. Configure WordPress Endpoint
// src/config/cms.js
export default {
source: 'wordpress',
wordpress: {
endpoint: 'https://your-site.com/wp-json/wp/v2',
cache: true,
ttl: 3600, // 1 hour
auth: {
// Optional: for private posts
username: process.env.WP_USERNAME,
password: process.env.WP_APP_PASSWORD,
},
},
};
3. Update Content Collection
// src/content/config.ts
import { defineCollection } from 'astro:content';
import { wordPressLoader } from '@astrojs/wordpress';
import cmsConfig from '../config/cms.js';
import { cmsSchema } from './schemas/cms.js';
const cms = defineCollection({
loader: wordPressLoader({
endpoint: cmsConfig.wordpress.endpoint,
cache: cmsConfig.wordpress.cache,
ttl: cmsConfig.wordpress.ttl,
}),
schema: cmsSchema,
});
export const collections = { cms };
Data Mapping
WordPress data automatically maps to CMS schema:
| WordPress Field | CMS Field | Notes |
|---|---|---|
title.rendered | title | Post title |
excerpt.rendered | excerpt | Post excerpt |
content.rendered | body | Post content (HTML) |
date | pubDate | Publication date |
modified | updatedDate | Last modified |
author | author | Author name |
categories | categories | Category names |
tags | tags | Tag names |
featured_media | heroImage | Featured image URL |
slug | slug | Post slug |
parent | parent | Parent page slug |
Custom Fields
Map WordPress custom fields:
// WordPress Advanced Custom Fields (ACF)
const loader = wordPressLoader({
endpoint: 'https://your-site.com/wp-json/wp/v2',
transform: (post) => ({
title: post.title.rendered,
body: post.content.rendered,
// Custom fields
layout: post.acf.layout_type,
showToc: post.acf.show_toc,
featured: post.acf.is_featured,
}),
});
Hierarchical Pages
WordPress parent-child relationships work automatically:
WordPress Page Hierarchy:
├── Docs (parent: null)
│ ├── Getting Started (parent: docs)
│ └── API Reference (parent: docs)
Becomes:
/docs/
/docs/getting-started/
/docs/api-reference/
Sanity Integration
Setup
1. Install Sanity Loader
npm install @sanity/astro-loader
2. Configure Sanity
// src/config/cms.js
export default {
source: 'sanity',
sanity: {
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET || 'production',
apiVersion: '2023-05-03',
useCdn: true,
},
};
3. Create Sanity Loader
// src/content/config.ts
import { defineCollection } from 'astro:content';
import { sanityLoader } from '@sanity/astro-loader';
import cmsConfig from '../config/cms.js';
const cms = defineCollection({
loader: sanityLoader({
projectId: cmsConfig.sanity.projectId,
dataset: cmsConfig.sanity.dataset,
query: `
*[_type == "post"] {
_id,
title,
slug,
body,
publishedAt,
author->{name},
categories[]->title,
"heroImage": mainImage.asset->url
}
`,
transform: (doc) => ({
id: doc._id,
slug: doc.slug.current,
data: {
title: doc.title,
body: doc.body,
pubDate: doc.publishedAt,
author: doc.author.name,
categories: doc.categories,
heroImage: doc.heroImage,
},
}),
}),
schema: cmsSchema,
});
GROQ Queries
Customize what content to fetch:
// All published posts
*[_type == "post" && !(_id in path("drafts.**"))]
// Posts with specific category
*[_type == "post" && "documentation" in categories[]->slug.current]
// Hierarchical content
*[_type == "post"] {
...,
parent->{slug}
}
Contentful Integration
Setup
1. Install Contentful Loader
npm install contentful
2. Configure Contentful
// src/config/cms.js
export default {
source: 'contentful',
contentful: {
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
environment: 'master',
},
};
3. Create Custom Loader
// src/loaders/contentful.js
import { createClient } from 'contentful';
export function contentfulLoader(config) {
const client = createClient({
space: config.space,
accessToken: config.accessToken,
});
return {
name: 'contentful-loader',
async load() {
const entries = await client.getEntries({
content_type: 'post',
});
return entries.items.map((item) => ({
id: item.sys.id,
slug: item.fields.slug,
data: {
title: item.fields.title,
body: item.fields.body,
pubDate: item.fields.publishDate,
author: item.fields.author?.fields.name,
categories: item.fields.categories || [],
heroImage: item.fields.heroImage?.fields.file.url,
},
}));
},
};
}
Hybrid Mode
Run Multiple Sources Simultaneously
Combine local markdown with headless CMS:
// src/content/config.ts
import { glob } from 'astro/loaders';
import { wordPressLoader } from '@astrojs/wordpress';
// Local markdown content
const cmsLocal = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/cms' }),
schema: cmsSchema,
});
// WordPress content
const cmsWordPress = defineCollection({
loader: wordPressLoader({ endpoint: '...' }),
schema: cmsSchema,
});
// Merge both sources
export const collections = {
cms: mergeSources([cmsLocal, cmsWordPress]),
};
Use Cases for Hybrid Mode
Migration Period:
- Gradually move content from markdown to CMS
- Test CMS before full migration
- Maintain fallback during transition
Content Split:
- Core pages in markdown (fast, version-controlled)
- Blog posts in WordPress (client can edit)
- Documentation in Notion (team collaboration)
Multi-Source Aggregation:
- Aggregate content from multiple sources
- Unified search across all content
- Single consistent design
Custom Loaders
Create Your Own Loader
// src/loaders/custom-api.js
export function customAPILoader(config) {
return {
name: 'custom-api-loader',
async load() {
const response = await fetch(config.endpoint);
const data = await response.json();
return data.items.map((item) => ({
id: item.id,
slug: item.slug,
data: {
title: item.title,
body: item.content,
pubDate: new Date(item.published_at),
author: item.author.name,
categories: item.categories,
tags: item.tags,
heroImage: item.featured_image,
parent: item.parent_slug,
},
}));
},
// Optional: watch for changes during dev
async watch(onChange) {
// Implement webhook listener or polling
},
};
}
Use Custom Loader
// src/content/config.ts
import { customAPILoader } from '../loaders/custom-api.js';
const cms = defineCollection({
loader: customAPILoader({
endpoint: 'https://api.example.com/posts',
}),
schema: cmsSchema,
});
Caching & Performance
Build-Time Caching
Cache CMS responses during build:
wordPressLoader({
endpoint: '...',
cache: true,
ttl: 3600, // Cache for 1 hour
});
Incremental Builds
Only fetch changed content:
const loader = wordPressLoader({
endpoint: '...',
incremental: true,
lastBuild: process.env.LAST_BUILD_TIME,
});
CDN Caching
Static pages cached at CDN:
- WordPress content fetched once at build
- Served as static HTML
- No runtime CMS queries
- Lightning-fast delivery
Webhooks & Auto-Rebuild
Netlify Build Hooks
Trigger rebuild when content changes:
1. Create Build Hook
Netlify Dashboard → Site Settings → Build & Deploy → Build Hooks
2. Configure WordPress
Install “WP Webhooks” plugin:
// WordPress: Trigger Netlify rebuild on post publish
add_action('publish_post', function($post_id) {
wp_remote_post('https://api.netlify.com/build_hooks/YOUR_HOOK_ID');
});
3. Configure Sanity
// sanity.config.js
export default defineConfig({
// ...
webhooks: [
{
name: 'netlify-rebuild',
url: 'https://api.netlify.com/build_hooks/YOUR_HOOK_ID',
on: ['create', 'update', 'delete'],
filter: '_type == "post"',
},
],
});
Content Preview
Draft Content Preview
Enable preview mode for drafts:
// src/pages/preview.astro
---
const { token, id } = Astro.url.searchParams;
// Verify preview token
if (token !== process.env.PREVIEW_TOKEN) {
return Astro.redirect('/404');
}
// Fetch draft content
const draft = await fetchDraftContent(id);
---
<Layout>
<article>
<h1>{draft.title}</h1>
<div set:html={draft.body} />
</article>
</Layout>
Preview Links
WordPress/Sanity preview links:
WordPress:
https://yoursite.com/preview?token=SECRET&id=POST_ID
Sanity:
https://yoursite.com/preview?token=SECRET&id=DOCUMENT_ID
Data Transformation
Markdown Conversion
Convert HTML to Markdown:
import TurndownService from 'turndown';
const turndown = new TurndownService();
const loader = wordPressLoader({
transform: (post) => ({
...post,
body: turndown.turndown(post.content.rendered),
}),
});
Image Processing
Process CMS images:
transform: (post) => ({
...post,
heroImage: post.featured_media ? `${post.featured_media}?w=1200&h=630&fit=crop` : null,
});
Authentication
Private Content
Access password-protected content:
wordPressLoader({
endpoint: '...',
auth: {
username: process.env.WP_USERNAME,
password: process.env.WP_APP_PASSWORD, // Application password
},
});
API Keys
Secure API access:
sanityLoader({
projectId: '...',
dataset: '...',
token: process.env.SANITY_READ_TOKEN, // For private datasets
});
Troubleshooting
Content Not Updating
Issue: Changes in CMS not reflected on site
Solutions:
- Trigger manual rebuild
- Check webhook configuration
- Verify cache TTL settings
- Clear build cache
Schema Validation Errors
Issue: CMS data doesn’t match schema
Solutions:
- Update schema to match CMS fields
- Transform data in loader
- Provide default values
- Make fields optional
Performance Issues
Issue: Slow builds with large content
Solutions:
- Enable caching (
cache: true) - Use incremental builds
- Limit content fetched
- Paginate API requests
Migration Strategies
Gradual Migration
- Week 1: Set up headless CMS
- Week 2: Import existing content
- Week 3: Run hybrid mode (markdown + CMS)
- Week 4: Migrate remaining content
- Week 5: Remove markdown loader
Content Export
Export markdown to CMS:
// scripts/export-to-cms.js
import { getCollection } from 'astro:content';
import { createPost } from './cms-api.js';
const posts = await getCollection('cms');
for (const post of posts) {
await createPost({
title: post.data.title,
content: post.body,
// ...
});
}
Best Practices
- Start with Markdown: Build and test with markdown first
- Plan Schema Early: Design schema to work with multiple sources
- Use Environment Variables: Keep credentials secure
- Enable Caching: Speed up builds
- Set Up Webhooks: Auto-rebuild on content changes
- Test Locally: Use
.env.localfor local CMS testing - Monitor Build Times: Track performance with different CMSs
- Version Your Content: Use git for markdown, CMS versioning for headless
Resources
If you enjoyed this article, please share it:
Written by
CMS Documentation Team