SEO for CMS-driven content
Most content management systems ship with rich text editors that prioritize author convenience over search engine optimization. The problem: editors generate messy markup, inconsistent heading hierarchies, unoptimized images, and broken internal link patterns that harm your SEO performance in ways that are hard to catch manually. In this post, I'll walk you through four critical fixes you need to implement at the code level to salvage SEO from CMS-driven content.
After building multiple production platforms handling user-generated and editorial content, I've learned that SEO problems in CMS output aren't edge cases—they're the default. Your job as a developer is to normalize that output before it reaches the browser.
Normalize headings — content starts at H2
The most common headings problem: editors skip directly from page title (H1) to H3 or H4, or worse, they pepper content with multiple H1s because they don't understand semantic hierarchy. Search engines treat heading structure as an outline. A broken outline confuses both algorithms and screen readers.
The rule is straightforward: after your page H1, all CMS content should start at H2 and nest logically (H2 → H3 → H4). Never let CMS output contain H1s or skip levels.
Here's what bad CMS markup looks like:
<h1>How to Build a REST API</h1>
<h3>Authentication</h3> <!-- Skipped H2, confuses bots -->
<p>Use JWT tokens...</p>
<h1>Security Best Practices</h1> <!-- Second H1 on same page -->
<h2>CORS Setup</h2>Here's the normalized version:
<h1>How to Build a REST API</h1>
<h2>Authentication</h2> <!-- Start at H2 -->
<p>Use JWT tokens...</p>
<h2>Security Best Practices</h2> <!-- Same level, not H1 -->
<h3>CORS Setup</h3> <!-- Nested under H2 -->Here's a Node.js function to normalize headings from CMS content:
function normalizeHeadings(htmlContent) {
const cheerio = require('cheerio');
const $ = cheerio.load(htmlContent);
// Remove all H1s from CMS body content
$('h1').each((i, el) => {
const h2 = $('<h2>').html($(el).html());
$(el).replaceWith(h2);
});
// Fix heading jumps: H3 without H2 becomes H2, etc
let expectedLevel = 2;
const headings = $('h1, h2, h3, h4, h5, h6');
headings.each((i, el) => {
const currentTag = el.name; // 'h2', 'h3', etc
const currentLevel = parseInt(currentTag[1]);
// If we jump more than 1 level, normalize to expected
if (currentLevel > expectedLevel + 1) {
const newTag = `h${expectedLevel + 1}`;
const h = $(`<${newTag}>`).html($(el).html());
$(el).replaceWith(h);
expectedLevel += 1;
} else {
expectedLevel = currentLevel;
}
});
return $.html();
}Run this normalization at content save time in your CMS, not at render time. Rendering normalization adds latency and creates caching issues.
Add width and height to embedded images
CMS editors embed images without dimension attributes. Modern browsers still render them, but Core Web Vitals and SEO tools penalize you for layout shift. When the browser doesn't know image dimensions upfront, it reserves zero space, then reflows when the image loads.
This is a straightforward optimization that often feels like a quick win: crawl your CMS output, find every image tag, query the file metadata, inject width and height attributes. But it requires coordination between your backend and image storage.
Bad CMS output:
<img src="https://media.expona.dev/uploads/blog/api-design.jpg" alt="REST API design patterns">Good output:
<img src="https://media.expona.dev/uploads/blog/api-design.jpg"
alt="REST API design patterns"
width="800"
height="600">If you're using AWS S3, you can fetch image dimensions without downloading the file:
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const sharp = require('sharp');
async function enrichImageWithDimensions(s3Key) {
try {
const data = await s3.getObject({ Bucket: 'my-bucket', Key: s3Key }).promise();
const metadata = await sharp(data.Body).metadata();
return {
width: metadata.width,
height: metadata.height,
format: metadata.format
};
} catch (err) {
console.error(`Failed to get dimensions for ${s3Key}`, err);
return null;
}
}
// In your content pipeline:
function enrichContent(htmlContent, imageMap) {
const cheerio = require('cheerio');
const $ = cheerio.load(htmlContent);
$('img').each((i, el) => {
const src = $(el).attr('src');
if (imageMap[src]) {
const { width, height } = imageMap[src];
$(el).attr('width', width).attr('height', height);
}
});
return $.html();
}Cache these dimensions in a database table: image_path → width, height. When an image is updated, invalidate its cache entry. This prevents repeated file operations.
Rewrite internal links to canonical URLs
CMS editors often create internal links pointing to non-canonical URLs: pages with query parameters, trailing slashes inconsistency, uppercase paths, or unresolved redirect chains. Search engines follow these links but waste crawl budget and split link equity across variants.
Example: an editor creates a link to /blog/seo-tips?utm_source=cms when the canonical is /blog/seo-tips/. You end up with duplicate crawl targets.
The fix: parse every internal link in CMS content at save time, resolve it to its canonical form, and rewrite it.
const canonicalLinkMap = {
'/blog/seo-tips': '/blog/seo-tips/',
'/blog/seo-tips?utm_source=cms': '/blog/seo-tips/',
'/about': '/about-us/',
'/contact-us': '/contact/'
};
function rewriteInternalLinks(htmlContent, canonicalMap) {
const cheerio = require('cheerio');
const $ = cheerio.load(htmlContent);
$('a[href*="/"]').each((i, el) => {
let href = $(el).attr('href');
// Skip external links, anchors, and protocols
if (href.startsWith('http') || href.startsWith('#') || !href.startsWith('/')) {
return;
}
// Remove query params and fragments temporarily
const [pathOnly] = href.split('?');
const canonical = canonicalMap[pathOnly] || pathOnly;
// Rewrite to canonical (you might add ?utm params back server-side)
$(el).attr('href', canonical);
});
return $.html();
}Build your canonical map by crawling your own site with Lighthouse or a headless browser. Log every redirect chain and store the final canonical URL. Redeploy this map whenever your routing changes.
Pro tip: test internal links at content preview time. Show editors a warning if they link to a page that will redirect.
Strip empty paragraphs and inline styles
CMS rich text editors generate bloated markup: empty <p> tags for spacing, inline style="" attributes instead of classes, and extra <br> tags. This inflates page size and adds noise that makes your HTML harder to index.
Example of messy output:
<p></p>
<p style="margin-bottom: 20px; font-size: 16px; color: #333;">
REST APIs dominate modern backend design.
</p>
<p><br></p>
<p><br></p>
<p style="font-weight: bold;">Key takeaway</p>Cleaned version:
<p>REST APIs dominate modern backend design.</p>
<p><strong>Key takeaway</strong></p>Here's a cleanup function:
function cleanupContent(htmlContent) {
const cheerio = require('cheerio');
const $ = cheerio.load(htmlContent);
// Remove empty paragraphs and paragraphs with only whitespace/br
$('p').each((i, el) => {
const text = $(el).text().trim();
const children = $(el).children().length;
if (!text || (children === 1 && $(el).find('br').length === 1)) {
$(el).remove();
}
});
// Remove inline styles, keep semantic tags
$('[style]').each((i, el) => {
const style = $(el).attr('style');
// Preserve bold/italic intent via semantic tags
if (style.includes('bold') || style.includes('font-weight: 700')) {
const strong = $('<strong>').html($(el).html());
$(el).replaceWith(strong);
} else if (style.includes('italic') || style.includes('font-style: italic')) {
const em = $('<em>').html($(el).html());
$(el).replaceWith(em);
} else {
// For other styles, just remove the attribute
$(el).removeAttr('style');
}
});
// Remove consecutive br tags
while ($('br + br').length > 0) {
$('br + br').remove();
}
return $.html();
}Run this cleanup after your normalization pipeline. The order matters: fix structure first (headings, links), then clean formatting.
Common mistakes
Mistake 1: Cleaning content at render time. Parsing and rewriting HTML on every page view adds 50-200ms latency. Do it once at save time, store the clean version, and serve it cached. Your database should always contain production-ready HTML.
Mistake 2: Assuming all images have accessible dimensions in S3 metadata. Some image processing pipelines strip metadata. Always validate and have a fallback. Log failures so you can audit which images are missing dimensions.
Mistake 3: Not testing your normalization rules against existing content. When you deploy new cleanup rules, they might break old content that relied on the old structure. Write a migration script, test it against a content sample, and validate the output before applying it to production.
Mistake 4: Forgetting to handle redirect chains in canonical rewrites. If /old-path redirects to /new-path, which redirects to /canonical-path, your canonical map needs the final destination, not an intermediate redirect.
Key takeaways
- Normalize heading hierarchy: strip H1s from CMS body, ensure H2 starts the content, never skip levels. Run normalization at save time, not render time.
- Add width and height to all images by fetching metadata from your image storage. Cache dimensions in a database to avoid repeated file operations.
- Rewrite internal links to canonical URLs at save time. Build a canonical map by crawling your site and tracking redirect chains.
- Remove empty paragraphs, consecutive breaks, and inline styles. Convert style-based formatting (bold, italic) to semantic HTML tags.
- Always validate cleanup rules against existing content before deploying. Monitor failures and log edge cases for manual review.
- Store cleaned, optimized HTML in your database. Your CMS should be the source of truth for production-ready markup.






Comments
Loading comments…