Contentful Content Validation and Quality Gates
A comprehensive guide to Contentful content validation covering quality gates, automated checks, webhook-based validation, and content quality scoring with Node.js.
Contentful Content Validation and Quality Gates
Publishing bad content is worse than publishing no content. Broken links, missing meta descriptions, images without alt text, thin pages with 200 words — these problems erode trust with readers and tank your search rankings. Contentful gives you a solid foundation with built-in field validations, but real content quality requires more than "this field is required." You need automated quality gates that catch problems before they reach production.
I have been running content operations on Contentful for years, and the pattern that works is straightforward: validate at the field level with Contentful's built-in tools, then layer on programmatic validation through webhooks that score content quality and block publishing when the score falls below your threshold. This article walks through the entire stack, from basic field constraints to a complete quality gate system with a dashboard.
Built-In Field Validations
Contentful provides a solid set of field-level validations out of the box. You configure these through the content model editor or programmatically through the Management API. Every content type should have these locked down before you think about custom validation.
Required fields are the baseline. Title, slug, meta description, body content, and featured image should all be required on any content type that renders a page.
Unique fields prevent duplicate slugs. Set this on your slug field to avoid URL collisions.
Regex patterns enforce formatting. Use them for slugs (^[a-z0-9]+(?:-[a-z0-9]+)*$), email fields, or any field with a strict format.
Range validations constrain numbers and dates. Set minimum and maximum values for numeric fields like reading time estimates or priority scores.
Size validations control text length. Set a minimum of 120 and maximum of 160 characters on your meta description field. Set a minimum word count on body content.
Predefined values restrict selections. Categories, statuses, and content types should use a fixed list of allowed values.
Link validations restrict which content types a reference field can point to. An "Author" reference field should only accept entries of type "Author," not any random entry.
Here is how to set these up programmatically with the Management API:
var contentful = require("contentful-management");
var client = contentful.createClient({
accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
});
function configureValidations() {
return client
.getSpace(process.env.CONTENTFUL_SPACE_ID)
.then(function (space) {
return space.getEnvironment("master");
})
.then(function (environment) {
return environment.getContentType("blogPost");
})
.then(function (contentType) {
// Find the meta description field and add size validation
var metaField = contentType.fields.find(function (f) {
return f.id === "metaDescription";
});
if (metaField) {
metaField.validations = [
{ size: { min: 120, max: 160 } },
{
message:
"Meta description must be between 120 and 160 characters.",
},
];
}
// Find the slug field and add regex + unique validations
var slugField = contentType.fields.find(function (f) {
return f.id === "slug";
});
if (slugField) {
slugField.validations = [
{ unique: true },
{
regexp: {
pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$",
flags: null,
},
message: "Slug must be lowercase alphanumeric with hyphens only.",
},
];
}
return contentType.update();
})
.then(function (contentType) {
return contentType.publish();
})
.then(function () {
console.log("Validations configured successfully.");
});
}
configureValidations();
Built-in validations catch the obvious problems. But they cannot check whether your links actually resolve, whether your content meets SEO requirements holistically, or whether the writing quality is acceptable. That is where programmatic validation comes in.
Programmatic Validation with the Management API
The Management API lets you read, validate, and modify entries before they go live. You can build scripts that audit your entire content library or validate individual entries on demand.
var contentful = require("contentful-management");
var client = contentful.createClient({
accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
});
function validateEntry(spaceId, environmentId, entryId) {
var results = {
entryId: entryId,
errors: [],
warnings: [],
score: 100,
};
return client
.getSpace(spaceId)
.then(function (space) {
return space.getEnvironment(environmentId);
})
.then(function (environment) {
return environment.getEntry(entryId);
})
.then(function (entry) {
var fields = entry.fields;
var locale = "en-US";
// Check title length
var title = fields.title ? fields.title[locale] : "";
if (!title || title.length === 0) {
results.errors.push("Title is missing.");
results.score -= 25;
} else if (title.length > 60) {
results.warnings.push(
"Title exceeds 60 characters (" + title.length + "). May be truncated in search results."
);
results.score -= 5;
}
// Check meta description
var metaDesc = fields.metaDescription
? fields.metaDescription[locale]
: "";
if (!metaDesc || metaDesc.length === 0) {
results.errors.push("Meta description is missing.");
results.score -= 20;
} else if (metaDesc.length < 120) {
results.warnings.push(
"Meta description is short (" + metaDesc.length + " chars). Aim for 120-160."
);
results.score -= 10;
}
// Check body word count
var body = fields.body ? fields.body[locale] : "";
var wordCount = body.split(/\s+/).filter(function (w) {
return w.length > 0;
}).length;
if (wordCount < 300) {
results.errors.push(
"Body has only " + wordCount + " words. Minimum is 300."
);
results.score -= 30;
} else if (wordCount < 800) {
results.warnings.push(
"Body has " + wordCount + " words. Consider expanding for better SEO."
);
results.score -= 5;
}
// Check for images with alt text
var imageMatches = body.match(/!\[([^\]]*)\]\([^)]+\)/g) || [];
imageMatches.forEach(function (img, index) {
var altMatch = img.match(/!\[([^\]]*)\]/);
if (altMatch && altMatch[1].trim().length === 0) {
results.errors.push("Image " + (index + 1) + " is missing alt text.");
results.score -= 10;
}
});
results.score = Math.max(0, results.score);
return results;
});
}
This function gives you a validation result with a numeric score. But running it manually defeats the purpose. You want this to fire automatically when content changes.
Webhook-Based Quality Gates
Contentful webhooks fire on entry lifecycle events: create, save, auto-save, publish, unpublish, archive, and delete. The one you care about for quality gates is the Entry.publish event. When an editor clicks Publish, Contentful sends a POST request to your endpoint with the full entry payload.
The strategy: configure a webhook that fires on publish, validate the content, and if it fails your quality threshold, immediately unpublish it via the Management API and notify the editor.
Here is the complete quality gate system.
Project Setup
content-quality-gate/
server.js
lib/
validators.js
link-checker.js
scorer.js
contentful-client.js
routes/
webhook.js
dashboard.js
views/
dashboard.pug
data/
results.json
package.json
{
"name": "content-quality-gate",
"version": "1.0.0",
"dependencies": {
"contentful-management": "^10.0.0",
"express": "^4.18.0",
"node-fetch": "^2.7.0",
"pug": "^3.0.0"
}
}
The Contentful Client Module
// lib/contentful-client.js
var contentful = require("contentful-management");
var client = contentful.createClient({
accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
});
function getEnvironment(spaceId, envId) {
return client.getSpace(spaceId).then(function (space) {
return space.getEnvironment(envId || "master");
});
}
function unpublishEntry(spaceId, envId, entryId) {
return getEnvironment(spaceId, envId)
.then(function (environment) {
return environment.getEntry(entryId);
})
.then(function (entry) {
if (entry.isPublished()) {
return entry.unpublish();
}
return entry;
});
}
function getEntry(spaceId, envId, entryId) {
return getEnvironment(spaceId, envId).then(function (environment) {
return environment.getEntry(entryId);
});
}
function getAsset(spaceId, envId, assetId) {
return getEnvironment(spaceId, envId).then(function (environment) {
return environment.getAsset(assetId);
});
}
module.exports = {
getEnvironment: getEnvironment,
unpublishEntry: unpublishEntry,
getEntry: getEntry,
getAsset: getAsset,
};
The Validators Module
// lib/validators.js
var fetch = require("node-fetch");
function validateSEO(fields, locale) {
var issues = [];
var loc = locale || "en-US";
var title = fields.title ? fields.title[loc] : "";
var metaDesc = fields.metaDescription ? fields.metaDescription[loc] : "";
var slug = fields.slug ? fields.slug[loc] : "";
if (!title || title.length === 0) {
issues.push({ type: "error", rule: "seo", message: "Title is missing." });
} else if (title.length > 60) {
issues.push({
type: "warning",
rule: "seo",
message: "Title is " + title.length + " chars. Keep under 60 for search.",
});
}
if (!metaDesc || metaDesc.length === 0) {
issues.push({
type: "error",
rule: "seo",
message: "Meta description is missing.",
});
} else if (metaDesc.length < 120 || metaDesc.length > 160) {
issues.push({
type: "warning",
rule: "seo",
message: "Meta description is " + metaDesc.length + " chars. Target 120-160.",
});
}
if (!slug || slug.length === 0) {
issues.push({ type: "error", rule: "seo", message: "Slug is missing." });
} else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
issues.push({
type: "warning",
rule: "seo",
message: "Slug contains invalid characters.",
});
}
return issues;
}
function validateContent(fields, locale) {
var issues = [];
var loc = locale || "en-US";
var body = fields.body ? fields.body[loc] : "";
// Word count check
var words = body.split(/\s+/).filter(function (w) {
return w.length > 0;
});
if (words.length < 300) {
issues.push({
type: "error",
rule: "content",
message: "Body has " + words.length + " words. Minimum is 300.",
});
} else if (words.length < 800) {
issues.push({
type: "warning",
rule: "content",
message: "Body has " + words.length + " words. 800+ recommended.",
});
}
// Heading structure check
var headings = body.match(/^#{1,6}\s+.+$/gm) || [];
if (headings.length === 0 && words.length > 500) {
issues.push({
type: "warning",
rule: "content",
message: "No headings found in a long article. Add structure.",
});
}
// Check for images without alt text
var images = body.match(/!\[([^\]]*)\]\([^)]+\)/g) || [];
images.forEach(function (img, i) {
var alt = img.match(/!\[([^\]]*)\]/);
if (alt && alt[1].trim() === "") {
issues.push({
type: "error",
rule: "accessibility",
message: "Image " + (i + 1) + " is missing alt text.",
});
}
});
return issues;
}
function validateImages(fields, locale, contentfulClient, spaceId, envId) {
var issues = [];
var loc = locale || "en-US";
var featuredImage = fields.featuredImage ? fields.featuredImage[loc] : null;
if (!featuredImage) {
issues.push({
type: "error",
rule: "image",
message: "Featured image is missing.",
});
return Promise.resolve(issues);
}
var assetId = featuredImage.sys.id;
return contentfulClient
.getAsset(spaceId, envId, assetId)
.then(function (asset) {
var file = asset.fields.file[loc];
if (!file) {
issues.push({
type: "error",
rule: "image",
message: "Featured image file is not uploaded.",
});
return issues;
}
// Check file size (max 2MB)
var sizeInMB = file.details.size / (1024 * 1024);
if (sizeInMB > 2) {
issues.push({
type: "warning",
rule: "image",
message: "Featured image is " + sizeInMB.toFixed(1) + "MB. Keep under 2MB.",
});
}
// Check dimensions
var dimensions = file.details.image;
if (dimensions) {
if (dimensions.width < 1200) {
issues.push({
type: "warning",
rule: "image",
message: "Featured image width is " + dimensions.width + "px. Recommend 1200px+.",
});
}
}
// Check alt text on the asset
var assetTitle = asset.fields.title ? asset.fields.title[loc] : "";
var assetDesc = asset.fields.description
? asset.fields.description[loc]
: "";
if (!assetTitle && !assetDesc) {
issues.push({
type: "error",
rule: "accessibility",
message: "Featured image asset has no title or description for alt text.",
});
}
return issues;
})
.catch(function (err) {
issues.push({
type: "error",
rule: "image",
message: "Could not validate featured image: " + err.message,
});
return issues;
});
}
module.exports = {
validateSEO: validateSEO,
validateContent: validateContent,
validateImages: validateImages,
};
The Link Checker
// lib/link-checker.js
var fetch = require("node-fetch");
function extractLinks(markdown) {
var linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g;
var links = [];
var match;
while ((match = linkPattern.exec(markdown)) !== null) {
links.push({
text: match[1],
url: match[2],
});
}
return links;
}
function checkLink(url, baseUrl) {
var fullUrl = url;
// Handle relative URLs
if (url.startsWith("/")) {
fullUrl = baseUrl + url;
}
// Skip anchors and mailto links
if (url.startsWith("#") || url.startsWith("mailto:")) {
return Promise.resolve({ url: url, status: "skipped", ok: true });
}
return fetch(fullUrl, {
method: "HEAD",
timeout: 10000,
headers: { "User-Agent": "ContentQualityBot/1.0" },
})
.then(function (response) {
return {
url: url,
status: response.status,
ok: response.ok,
};
})
.catch(function (err) {
return {
url: url,
status: "error",
ok: false,
error: err.message,
};
});
}
function checkAllLinks(markdown, baseUrl) {
var links = extractLinks(markdown);
if (links.length === 0) {
return Promise.resolve([]);
}
// Check links in batches of 5 to avoid hammering servers
var batchSize = 5;
var batches = [];
for (var i = 0; i < links.length; i += batchSize) {
batches.push(links.slice(i, i + batchSize));
}
var allResults = [];
return batches
.reduce(function (chain, batch) {
return chain.then(function () {
var checks = batch.map(function (link) {
return checkLink(link.url, baseUrl);
});
return Promise.all(checks).then(function (results) {
allResults = allResults.concat(results);
});
});
}, Promise.resolve())
.then(function () {
return allResults;
});
}
module.exports = {
extractLinks: extractLinks,
checkLink: checkLink,
checkAllLinks: checkAllLinks,
};
The Quality Scorer
// lib/scorer.js
var WEIGHTS = {
seo: { error: 15, warning: 5 },
content: { error: 20, warning: 5 },
accessibility: { error: 15, warning: 5 },
image: { error: 10, warning: 3 },
links: { error: 10, warning: 3 },
};
function calculateScore(issues) {
var score = 100;
var breakdown = {};
issues.forEach(function (issue) {
var rule = issue.rule || "general";
var weight = WEIGHTS[rule] || { error: 10, warning: 3 };
var deduction = issue.type === "error" ? weight.error : weight.warning;
score -= deduction;
if (!breakdown[rule]) {
breakdown[rule] = { errors: 0, warnings: 0, deductions: 0 };
}
breakdown[rule][issue.type === "error" ? "errors" : "warnings"] += 1;
breakdown[rule].deductions += deduction;
});
return {
score: Math.max(0, score),
grade: getGrade(Math.max(0, score)),
breakdown: breakdown,
totalErrors: issues.filter(function (i) {
return i.type === "error";
}).length,
totalWarnings: issues.filter(function (i) {
return i.type === "warning";
}).length,
};
}
function getGrade(score) {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
if (score >= 60) return "D";
return "F";
}
module.exports = {
calculateScore: calculateScore,
};
The Webhook Route
// routes/webhook.js
var express = require("express");
var router = express.Router();
var validators = require("../lib/validators");
var linkChecker = require("../lib/link-checker");
var scorer = require("../lib/scorer");
var contentfulClient = require("../lib/contentful-client");
var fs = require("fs");
var path = require("path");
var QUALITY_THRESHOLD = 70;
var RESULTS_FILE = path.join(__dirname, "..", "data", "results.json");
var BASE_URL = process.env.SITE_BASE_URL || "https://yoursite.com";
function loadResults() {
try {
var data = fs.readFileSync(RESULTS_FILE, "utf8");
return JSON.parse(data);
} catch (e) {
return {};
}
}
function saveResults(results) {
var dir = path.dirname(RESULTS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2));
}
router.post("/contentful", function (req, res) {
var payload = req.body;
var sys = payload.sys;
// Verify this is a publish event for an Entry
if (sys.type !== "Entry") {
return res.status(200).json({ message: "Not an entry. Skipping." });
}
var entryId = sys.id;
var spaceId = sys.space.sys.id;
var envId = sys.environment ? sys.environment.sys.id : "master";
var contentTypeId = sys.contentType.sys.id;
// Only validate specific content types
var validatableTypes = ["blogPost", "article", "page"];
if (validatableTypes.indexOf(contentTypeId) === -1) {
return res.status(200).json({ message: "Content type not validated." });
}
console.log("Validating entry: " + entryId + " (type: " + contentTypeId + ")");
// Respond immediately — processing happens async
res.status(200).json({ message: "Validation started." });
var fields = payload.fields;
var locale = "en-US";
var allIssues = [];
// Run SEO and content validations synchronously
var seoIssues = validators.validateSEO(fields, locale);
var contentIssues = validators.validateContent(fields, locale);
allIssues = allIssues.concat(seoIssues, contentIssues);
// Run async validations
var body = fields.body ? fields.body[locale] : "";
var imageValidation = validators.validateImages(
fields,
locale,
contentfulClient,
spaceId,
envId
);
var linkValidation = linkChecker.checkAllLinks(body, BASE_URL).then(function (linkResults) {
var brokenLinks = linkResults.filter(function (r) {
return !r.ok;
});
return brokenLinks.map(function (link) {
return {
type: "error",
rule: "links",
message: "Broken link: " + link.url + " (status: " + link.status + ")",
};
});
});
Promise.all([imageValidation, linkValidation])
.then(function (asyncResults) {
asyncResults.forEach(function (issues) {
allIssues = allIssues.concat(issues);
});
var scoreResult = scorer.calculateScore(allIssues);
// Store the result
var results = loadResults();
var title = fields.title ? fields.title[locale] : "Untitled";
results[entryId] = {
entryId: entryId,
title: title,
contentType: contentTypeId,
validatedAt: new Date().toISOString(),
score: scoreResult.score,
grade: scoreResult.grade,
totalErrors: scoreResult.totalErrors,
totalWarnings: scoreResult.totalWarnings,
breakdown: scoreResult.breakdown,
issues: allIssues,
blocked: scoreResult.score < QUALITY_THRESHOLD,
};
saveResults(results);
console.log(
"Entry " + entryId + ": score=" + scoreResult.score +
" grade=" + scoreResult.grade +
" errors=" + scoreResult.totalErrors
);
// Block publishing if below threshold
if (scoreResult.score < QUALITY_THRESHOLD) {
console.log(
"BLOCKING entry " + entryId + " — score " +
scoreResult.score + " is below threshold " + QUALITY_THRESHOLD
);
return contentfulClient.unpublishEntry(spaceId, envId, entryId);
}
})
.catch(function (err) {
console.error("Validation error for entry " + entryId + ":", err);
});
});
module.exports = router;
The Dashboard Route
// routes/dashboard.js
var express = require("express");
var router = express.Router();
var fs = require("fs");
var path = require("path");
var RESULTS_FILE = path.join(__dirname, "..", "data", "results.json");
router.get("/", function (req, res) {
var results = {};
try {
var data = fs.readFileSync(RESULTS_FILE, "utf8");
results = JSON.parse(data);
} catch (e) {
results = {};
}
var entries = Object.keys(results)
.map(function (key) {
return results[key];
})
.sort(function (a, b) {
return new Date(b.validatedAt) - new Date(a.validatedAt);
});
var stats = {
total: entries.length,
passing: entries.filter(function (e) {
return !e.blocked;
}).length,
blocked: entries.filter(function (e) {
return e.blocked;
}).length,
averageScore:
entries.length > 0
? Math.round(
entries.reduce(function (sum, e) {
return sum + e.score;
}, 0) / entries.length
)
: 0,
};
res.render("dashboard", {
title: "Content Quality Dashboard",
entries: entries,
stats: stats,
threshold: 70,
});
});
router.get("/entry/:id", function (req, res) {
var results = {};
try {
var data = fs.readFileSync(RESULTS_FILE, "utf8");
results = JSON.parse(data);
} catch (e) {
results = {};
}
var entry = results[req.params.id];
if (!entry) {
return res.status(404).json({ error: "Entry not found." });
}
res.json(entry);
});
module.exports = router;
The Express Server
// server.js
var express = require("express");
var path = require("path");
var app = express();
var PORT = process.env.PORT || 3000;
app.set("view engine", "pug");
app.set("views", path.join(__dirname, "views"));
app.use(express.json({ limit: "5mb" }));
app.use(express.static(path.join(__dirname, "public")));
// Webhook secret verification middleware
app.use("/webhook", function (req, res, next) {
var secret = req.headers["x-contentful-webhook-secret"];
if (process.env.WEBHOOK_SECRET && secret !== process.env.WEBHOOK_SECRET) {
return res.status(401).json({ error: "Invalid webhook secret." });
}
next();
});
var webhookRoutes = require("./routes/webhook");
var dashboardRoutes = require("./routes/dashboard");
app.use("/webhook", webhookRoutes);
app.use("/dashboard", dashboardRoutes);
app.get("/", function (req, res) {
res.redirect("/dashboard");
});
app.listen(PORT, function () {
console.log("Content Quality Gate running on port " + PORT);
});
The Dashboard View
//- views/dashboard.pug
doctype html
html
head
title= title
style.
body { font-family: -apple-system, sans-serif; margin: 2rem; background: #f5f5f5; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
.stat-card { background: white; padding: 1.5rem; border-radius: 8px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-card h3 { margin: 0 0 0.5rem; color: #666; font-size: 0.9rem; }
.stat-card .value { font-size: 2rem; font-weight: bold; }
.grade-A { color: #22c55e; } .grade-B { color: #84cc16; }
.grade-C { color: #eab308; } .grade-D { color: #f97316; } .grade-F { color: #ef4444; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f9f9f9; font-weight: 600; }
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; }
.badge-pass { background: #dcfce7; color: #166534; }
.badge-block { background: #fee2e2; color: #991b1b; }
body
h1 Content Quality Dashboard
.stats
.stat-card
h3 Total Validated
.value= stats.total
.stat-card
h3 Passing
.value.grade-A= stats.passing
.stat-card
h3 Blocked
.value.grade-F= stats.blocked
.stat-card
h3 Average Score
.value(class="grade-" + (stats.averageScore >= 90 ? "A" : stats.averageScore >= 80 ? "B" : stats.averageScore >= 70 ? "C" : stats.averageScore >= 60 ? "D" : "F"))= stats.averageScore
table
thead
tr
th Title
th Score
th Grade
th Errors
th Warnings
th Status
th Validated
tbody
each entry in entries
tr
td= entry.title
td= entry.score
td(class="grade-" + entry.grade)= entry.grade
td= entry.totalErrors
td= entry.totalWarnings
td
span.badge(class=entry.blocked ? "badge-block" : "badge-pass")= entry.blocked ? "BLOCKED" : "PASSED"
td= new Date(entry.validatedAt).toLocaleString()
Configuring the Contentful Webhook
In the Contentful web app, go to Settings > Webhooks and create a new webhook:
- URL:
https://your-server.com/webhook/contentful - Triggers: Select only
Entry > Publish - Headers: Add
x-contentful-webhook-secretwith a strong random value - Content type:
application/json - Payload: Select "Customize the webhook payload" and include
fieldsandsys
Set the WEBHOOK_SECRET environment variable on your server to match the header value.
Scheduled Content Audits
Quality does not end at publish time. Links rot, guidelines change, and content goes stale. Run periodic audits across your entire content library.
// scripts/audit.js
var contentful = require("contentful-management");
var validators = require("../lib/validators");
var linkChecker = require("../lib/link-checker");
var scorer = require("../lib/scorer");
var client = contentful.createClient({
accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
});
function auditAllEntries() {
var spaceId = process.env.CONTENTFUL_SPACE_ID;
var locale = "en-US";
return client
.getSpace(spaceId)
.then(function (space) {
return space.getEnvironment("master");
})
.then(function (environment) {
return environment.getEntries({
content_type: "blogPost",
limit: 1000,
"sys.publishedAt[exists]": true,
});
})
.then(function (response) {
var entries = response.items;
console.log("Auditing " + entries.length + " published entries...\n");
var reports = entries.map(function (entry) {
var fields = entry.fields;
var issues = []
.concat(validators.validateSEO(fields, locale))
.concat(validators.validateContent(fields, locale));
var scoreResult = scorer.calculateScore(issues);
return {
id: entry.sys.id,
title: fields.title ? fields.title[locale] : "Untitled",
score: scoreResult.score,
grade: scoreResult.grade,
errors: scoreResult.totalErrors,
publishedAt: entry.sys.publishedAt,
updatedAt: entry.sys.updatedAt,
};
});
// Sort by score ascending (worst first)
reports.sort(function (a, b) {
return a.score - b.score;
});
// Check for stale content (not updated in 6 months)
var sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
var staleEntries = reports.filter(function (r) {
return new Date(r.updatedAt) < sixMonthsAgo;
});
console.log("=== QUALITY REPORT ===");
console.log("Total entries: " + reports.length);
console.log("Stale entries (>6 months): " + staleEntries.length);
console.log("\nBottom 10 by score:");
reports.slice(0, 10).forEach(function (r) {
console.log(
" [" + r.grade + "] " + r.score + " - " + r.title + " (" + r.errors + " errors)"
);
});
return reports;
});
}
auditAllEntries().catch(function (err) {
console.error("Audit failed:", err);
process.exit(1);
});
Run this on a cron schedule — weekly is a good cadence. Pipe the output to Slack or email for visibility.
Content Freshness Monitoring
Stale content is a silent SEO killer. Track when articles were last reviewed and flag anything past its freshness date.
function checkFreshness(entry, locale) {
var loc = locale || "en-US";
var issues = [];
var updatedAt = new Date(entry.sys.updatedAt);
var now = new Date();
var daysSinceUpdate = Math.floor((now - updatedAt) / (1000 * 60 * 60 * 24));
if (daysSinceUpdate > 365) {
issues.push({
type: "error",
rule: "freshness",
message: "Content not updated in " + daysSinceUpdate + " days. Review required.",
});
} else if (daysSinceUpdate > 180) {
issues.push({
type: "warning",
rule: "freshness",
message: "Content not updated in " + daysSinceUpdate + " days. Consider reviewing.",
});
}
// Check if the content references any known deprecated topics
var body = entry.fields.body ? entry.fields.body[loc] : "";
var deprecatedPatterns = [
{ pattern: /Node\.js\s+(?:8|10|12|14)\b/g, message: "References outdated Node.js version" },
{ pattern: /Express\s+3\b/g, message: "References Express 3 (current is 4+)" },
];
deprecatedPatterns.forEach(function (dp) {
if (dp.pattern.test(body)) {
issues.push({ type: "warning", rule: "freshness", message: dp.message });
}
});
return issues;
}
CI/CD for Content: Validate Before Environment Promotion
Contentful environments let you stage content changes before promoting them to master. Add validation to your CI pipeline so content must pass quality checks before environment merge.
// scripts/validate-environment.js
var contentful = require("contentful-management");
var validators = require("../lib/validators");
var scorer = require("../lib/scorer");
var THRESHOLD = 70;
function validateEnvironment(envId) {
var client = contentful.createClient({
accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
});
var spaceId = process.env.CONTENTFUL_SPACE_ID;
var locale = "en-US";
var failures = [];
return client
.getSpace(spaceId)
.then(function (space) {
return space.getEnvironment(envId);
})
.then(function (environment) {
return environment.getEntries({ limit: 1000 });
})
.then(function (response) {
response.items.forEach(function (entry) {
var fields = entry.fields;
var issues = []
.concat(validators.validateSEO(fields, locale))
.concat(validators.validateContent(fields, locale));
var result = scorer.calculateScore(issues);
if (result.score < THRESHOLD) {
failures.push({
id: entry.sys.id,
title: fields.title ? fields.title[locale] : "Untitled",
score: result.score,
errors: result.totalErrors,
});
}
});
if (failures.length > 0) {
console.error("VALIDATION FAILED: " + failures.length + " entries below threshold.\n");
failures.forEach(function (f) {
console.error(" FAIL: " + f.title + " (score: " + f.score + ", errors: " + f.errors + ")");
});
process.exit(1);
}
console.log("All entries passed validation. Safe to promote.");
});
}
var envId = process.argv[2] || "staging";
validateEnvironment(envId).catch(function (err) {
console.error("Validation error:", err);
process.exit(1);
});
Add this to your CI pipeline:
# .github/workflows/content-validation.yml
name: Content Quality Gate
on:
workflow_dispatch:
inputs:
environment:
description: "Contentful environment to validate"
default: "staging"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install
- run: node scripts/validate-environment.js ${{ github.event.inputs.environment }}
env:
CONTENTFUL_MANAGEMENT_TOKEN: ${{ secrets.CONTENTFUL_MANAGEMENT_TOKEN }}
CONTENTFUL_SPACE_ID: ${{ secrets.CONTENTFUL_SPACE_ID }}
Common Issues and Troubleshooting
Webhook fires but validation never runs. Check that your webhook payload includes both sys and fields. By default, Contentful sends a minimal payload. You need to customize it or use the "Select specific events" option. Also verify the webhook secret header matches your environment variable exactly — a trailing newline will break it.
Unpublish fails with a 409 Conflict error. This happens when the entry version has changed between your read and write. The entry object includes a version number in sys.version, and the Management API uses optimistic locking. Fetch a fresh copy of the entry immediately before calling unpublish(). Do not cache entry objects.
Link checker reports false positives. Some sites block HEAD requests or return 403 for bot user agents. Fall back to a GET request with a truncated response if HEAD fails. Also add a whitelist for domains you know are valid but return odd status codes — GitHub raw URLs, for example, sometimes return 403 for HEAD requests.
Rate limiting on the Management API. Contentful enforces rate limits of roughly 7-10 requests per second on the Management API. If your audit script processes hundreds of entries, you will hit this. Add a delay between requests:
function delay(ms) {
return new Promise(function (resolve) {
setTimeout(resolve, ms);
});
}
// In your audit loop
entries.reduce(function (chain, entry) {
return chain.then(function () {
return validateEntry(entry).then(function () {
return delay(150);
});
});
}, Promise.resolve());
Webhook creates an infinite loop. If your validation logic publishes or unpublishes an entry, Contentful fires another webhook. Guard against this by checking the entry's publish count or adding a metadata field that marks entries as "validated." Alternatively, check if the entry was just unpublished by your system and skip re-validation.
Best Practices
Start with errors, not warnings. Begin by catching critical issues — missing titles, empty bodies, broken links. Add warning-level checks gradually. Too many warnings too early will train your editors to ignore them.
Set the threshold low initially, then raise it. Start with a quality threshold of 50 and bump it by 10 every month as your content improves. Going straight to 90 will block everything and frustrate your team.
Make validation results visible. The dashboard is not optional. Editors need to see why their content was blocked and exactly what to fix. A vague "quality check failed" message will generate support tickets.
Version your validation rules. Store the rule version with each validation result. When you change rules, you can re-audit old content against the new rules and track improvement over time.
Separate blocking rules from advisory rules. Missing meta description should block publishing. A slightly-too-long title should warn but not block. Be explicit about which rules are gate-level and which are guidance.
Test your validators against real content. Pull 20 published entries and run your validators against them before going live. You will find edge cases — markdown with HTML embedded, entries with multiple locales, rich text vs. plain text fields — that break naive parsing.
Log everything. Log every validation run with the entry ID, timestamp, score, and all issues. When an editor says "my article was blocked and I don't know why," you need to point to a specific log entry.