How to Integrate SEO Analysis Workflows with CMS Platforms Like Strapi
Problem
When I tried to set up an automated SEO analysis workflow for my Strapi v5 CMS, I ran into a fundamental mismatch. The original workflow assumed static HTML files where AI agents could directly read and modify markdown files. But my content lives in a database, accessed through APIs.
I asked on Reddit about this:
“From your description, website repo implies static html pages. Any recommended workflow for data on CMS? I don’t think keeping local files for all pages is the way to go.”
I got a helpful reply pointing out that if the CMS has an API or MCP support, I could integrate directly instead of maintaining local file copies.
Environment
- Strapi v5 CMS
- Next.js v16 frontend
- Model Context Protocol (MCP) for AI integration
- Node.js 22
What happened?
I have a Strapi v5 CMS serving content to a Next.js v16 frontend. When I wanted to run automated SEO analysis using AI agents, I initially tried to export all my content to local markdown files:
# My first naive approachnpm run export-content -- --output ./content-backupThis created hundreds of markdown files:
./content-backup/ ├── articles/ │ ├── article-1.mdx │ ├── article-2.mdx │ └── ... (200+ files) └── pages/ ├── page-1.mdx └── ... (50+ files)But this approach had obvious problems:
- Synchronization nightmare - every content update required re-export
- Version control bloat - tracking 250+ generated files
- Stale content risk - analysis might run on outdated exports
- Manual overhead - no automatic triggers when content changes
I needed a better approach that worked directly with the CMS API.
How to solve it?
I broke this into three layers: API access, webhook triggers, and MCP integration for AI agents.
Layer 1: Strapi v5 Content API Access
First, I needed to fetch content directly from Strapi. Here’s my initial attempt:
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
// First attempt - using v4 patternsconst fetchContent = async (contentType: string) => { const response = await fetch( `${STRAPI_URL}/api/${contentType}?publicationState=live`, { headers: { Authorization: `Bearer ${STRAPI_API_TOKEN}`, }, } ); const { data } = await response.json(); return data.attributes; // v4 pattern};This failed with:
TypeError: Cannot read properties of undefined (reading 'attributes')The problem? Strapi v5 changed the response format. I checked the documentation and found that v5 uses:
statusparameter instead ofpublicationState(‘draft’ | ‘published’)- Flattened response (no nested
attributesobject) documentIdinstead ofidfor document access
Here’s the corrected version:
const fetchStrapiContent = async ( contentType: string, status: 'published' | 'draft' = 'published') => { const response = await fetch( `${STRAPI_URL}/api/${contentType}?status=${status}`, { headers: { Authorization: `Bearer ${STRAPI_API_TOKEN}`, }, } );
if (!response.ok) { throw new Error(`Strapi API error: ${response.status}`); }
const { data } = await response.json(); // Strapi v5: direct access, no attributes nesting return data;};
// Fetch single document by documentIdconst fetchStrapiDocument = async ( contentType: string, documentId: string) => { const response = await fetch( `${STRAPI_URL}/api/${contentType}/${documentId}`, { headers: { Authorization: `Bearer ${STRAPI_API_TOKEN}`, }, } );
const { data } = await response.json(); return data;};Now when I fetch content:
const articles = await fetchStrapiContent('articles', 'published');console.log(articles[0].title); // Direct access in v5[ { documentId: 'abc123', title: 'My First Article', content: '...' } ]Layer 2: Webhook Integration for Real-Time Triggers
I wanted SEO analysis to run automatically when content changes. Strapi webhooks handle this:
{ "name": "SEO Analysis Trigger", "url": "https://my-seo-service.com/webhook/strapi", "events": ["entry.create", "entry.update", "entry.publish"], "headers": { "X-Strapi-Webhook-Secret": "your-webhook-secret-here" }}I set up a webhook handler:
import express from 'express';
const app = express();app.use(express.json());
app.post('/webhook/strapi', async (req, res) => { const signature = req.headers['x-strapi-webhook-secret'];
// Verify webhook signature if (signature !== process.env.STRAPI_WEBHOOK_SECRET) { return res.status(401).json({ error: 'Invalid signature' }); }
const { event, model, entry } = req.body;
// Trigger SEO analysis for the affected content if (event === 'entry.publish' || event === 'entry.update') { await triggerSEOAnalysis(model, entry.documentId); }
res.json({ received: true });});
async function triggerSEOAnalysis(contentType: string, documentId: string) { // Call MCP server to analyze content const response = await fetch('http://localhost:3001/mcp/analyze', { method: 'POST', body: JSON.stringify({ contentType, documentId }), });
return response.json();}Layer 3: MCP Server for AI Integration
The final layer connects AI agents to Strapi through MCP (Model Context Protocol). Here’s my MCP server:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";
const server = new McpServer({ name: "strapi-seo-connector", version: "1.0.0",});
// Tool: Fetch content for SEO analysisserver.tool( "fetch-content", { contentType: z.string().describe("Strapi content type (e.g., 'articles')"), documentId: z.string().optional().describe("Specific document ID"), status: z.enum(['published', 'draft']).optional().default('published'), }, async ({ contentType, documentId, status }) => { const content = documentId ? await fetchStrapiDocument(contentType, documentId) : await fetchStrapiContent(contentType, status);
return { content: [{ type: "text", text: JSON.stringify(content, null, 2), }], }; });
// Tool: Update SEO metadataserver.tool( "update-seo-metadata", { contentType: z.string(), documentId: z.string(), seoTitle: z.string().optional(), seoDescription: z.string().optional(), keywords: z.array(z.string()).optional(), }, async ({ contentType, documentId, seoTitle, seoDescription, keywords }) => { const response = await fetch( `${STRAPI_URL}/api/${contentType}/${documentId}`, { method: 'PUT', headers: { Authorization: `Bearer ${STRAPI_API_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ data: { seoTitle, seoDescription, keywords, }, }), } );
const { data } = await response.json(); return { content: [{ type: "text", text: `Updated SEO metadata for ${contentType}/${documentId}`, }], }; });
// Start the serverconst transport = new StdioServerTransport();await server.connect(transport);The Architecture
Here’s how the three layers connect:
┌───────────────┐ ┌──────────────┐ ┌─────────────┐│ Strapi CMS │ ──────→ │ Webhook │ ──────→ │ MCP Server ││ (Content API) │ │ Handler │ │ (AI Bridge) │└───────────────┘ └──────────────┘ └─────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ │ │ AI Agent │ │ │ │ (SEO Analysis) │ │ │ └─────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ ←──────────────────────┼───────────── │ Recommendations │ │ API Update │ │ & Metadata │ └─────────────────────────┘ └─────────────────┘The flow:
- Content created/updated in Strapi
- Webhook fires on
entry.publishorentry.update - MCP server fetches content via Strapi API
- AI agent analyzes content for SEO issues
- MCP server updates metadata back to Strapi
Common Mistakes I Made
I hit several issues during integration:
Mistake 1: Using v4 API patterns
// Wrong - v4 patternconst title = data.attributes.title;
// Correct - v5 patternconst title = data.title;Mistake 2: Storing API tokens in frontend code
// Wrong - exposed in Next.js client componentconst token = 'your-token-here'; // Never do this!
// Correct - server-side onlyconst token = process.env.STRAPI_API_TOKEN; // Backend onlyMistake 3: Ignoring rate limits
Strapi has default rate limits. I added throttling:
import pLimit from 'p-limit';
const limit = pLimit(5); // Max 5 concurrent requests
const fetchAllContent = async (contentTypes: string[]) => { const promises = contentTypes.map(type => limit(() => fetchStrapiContent(type)) );
return Promise.all(promises);};Mistake 4: Missing authentication
// Wrong - no auth headerawait fetch(`${STRAPI_URL}/api/articles`);
// Correct - include authawait fetch(`${STRAPI_URL}/api/articles`, { headers: { Authorization: `Bearer ${token}` },});The Benefits
With this three-layer approach:
- No local file overhead - Content stays in Strapi database
- Real-time analysis - Webhooks trigger SEO checks immediately on publish
- CMS-agnostic skills - MCP servers configurable for different providers (WordPress, Contentful, Sanity)
- Version safety - Analyze drafts before publishing
Summary
In this post, I showed how to integrate SEO analysis workflows with Strapi v5 CMS using a three-layer architecture: Content API for fetching, webhooks for real-time triggers, and MCP servers for AI agent integration. The key point is that you don’t need local file copies - the MCP server acts as a bridge between your CMS and AI agents, enabling automated SEO recommendations without synchronization overhead.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Strapi v5 Documentation
- 👨💻 Model Context Protocol (MCP) Specification
- 👨💻 Reddit Discussion: SEO Workflow with CMS
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments