
I wasted half a sprint shipping a blog platform before someone pointed out our focus keywords weren't appearing in a single H2. Google knew. Our traffic knew. We didn't — because we had no SEO check in our pipeline at all.
That started a two-week investigation into JavaScript SEO libraries. I ended up running two of them in a real 200-page Gatsby content site at the same time. Here's what I actually learned — including where each one loses.
The Core Problem: Most JS Projects Have Zero SEO Validation
If you're building with Next.js, Remix, or Gatsby, you're probably validating your TypeScript, linting your code, and running unit tests before every merge. But SEO? That usually gets checked manually — or not at all — until after Google has already indexed a half-optimized page.
There are two distinct moments where things can go wrong:
- Before the build — the content itself: Is the focus keyword in the title? In at least one H2? In the image alt text?
- After the build — the HTML structure: Does the page have a canonical tag? Is the title between 50–60 characters? Are all images tagged with alt attributes?
Two different problems. Two different tools. Let me show you both.
Tool 1: Checking Content Quality Before It Builds
This is where @power-seo/content-analysis shines. (Full disclosure: I'm one of the maintainers of this library — so take my enthusiasm with appropriate skepticism, but also know I understand its internals deeply.)
It's a TypeScript-first library that runs 13 on-page checks against your content fields — title, meta description, focus keyphrase, body HTML, slug, images — and returns a structured, scored report.
It works in Next.js Server Components, Remix loaders, Vercel Edge Functions, and plain Node.js scripts. No DOM dependency, no browser APIs.
Install:
npm i @power-seo/content-analysis
A real pre-merge CI gate for an MDX blog:
// scripts/seo-gate.ts
import { analyzeContent } from '@power-seo/content-analysis';
import { readFileSync } from 'fs';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
async function runSeoGate(mdxFilePath: string): Promise<void> {
const raw = readFileSync(mdxFilePath, 'utf-8');
const { data: frontmatter, content: mdContent } = matter(raw);
const vfile = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.process(mdContent);
const result = analyzeContent({
title: (frontmatter.title as string) ?? '',
metaDescription: (frontmatter.description as string) ?? '',
focusKeyphrase: (frontmatter.focusKeyphrase as string) ?? '',
content: String(vfile),
slug: (frontmatter.slug as string) ?? '',
});
const failures = result.results.filter((r) => r.status === 'poor');
if (failures.length > 0) {
console.error('SEO gate failed:');
failures.forEach((f) => console.error(' ✗', f.description));
process.exit(1);
}
console.log(`SEO gate passed — Score: ${result.score}/${result.maxScore}`);
}
runSeoGate(process.argv[2]!);
What this actually checks: keyphrase density (0.5–2.5%), keyphrase in the intro paragraph, keyphrase in at least one H2, keyphrase in the slug, image alt coverage, title length, meta description length, and more.
Run it in GitHub Actions before a PR merges:
# .github/workflows/seo-check.yml
- name: SEO gate
run: npx ts-node scripts/seo-gate.ts content/posts/my-new-post.mdx
If the keyword isn't distributed correctly across the content, the build fails. No more publishing posts that Google ignores.
What it doesn't do: You can't add custom rules. The 13 checks are fixed. If you need "fail if the post doesn't mention our product name," that logic lives outside this library.
Tool 2: Auditing HTML Structure After the Build
seo-analyzer is a different beast. It's a rule-based HTML checker — CLI-first, works on files, folders, URLs, and raw HTML strings. It has six built-in rules and supports fully custom async rule functions.
The killer feature is inputFolders(). Point it at your /public directory after gatsby build and it scans every HTML file automatically.
Install:
npm i -D seo-analyzer
Bulk post-build audit over a Gatsby /public folder:
// scripts/bulk-audit.js
const SeoAnalyzer = require('seo-analyzer');
const { writeFileSync } = require('fs');
new SeoAnalyzer()
.inputFolders(['public'])
.ignoreFolders(['public/404', 'public/_gatsby'])
.addRule('titleLengthRule', { min: 50, max: 60 })
.addRule('imgTagWithAltAttributeRule')
.addRule('metaBaseRule', { list: ['description', 'viewport'] })
.addRule('canonicalLinkRule')
.addRule('aTagWithRelAttributeRule')
.outputJson((json) => {
writeFileSync('seo-report.json', json);
console.log('Report written to seo-report.json');
})
.run();
Or skip the script entirely and use the CLI:
seo-analyzer -fl public
That one command scans 200 HTML files and surfaces every structural issue. For a post-deploy CI step, that's unbeatable.
Custom rules are where seo-analyzer really earns its place. Need every page to have a WebPage JSON-LD block?
const jsonLdRule = async (dom) => {
const scripts = dom.window.document.querySelectorAll(
'script[type="application/ld+json"]'
);
const hasWebPage = Array.from(scripts).some((s) => {
try {
return JSON.parse(s.textContent)['@type'] === 'WebPage';
} catch {
return false;
}
});
return hasWebPage ? [] : ['Missing WebPage JSON-LD structured data'];
};
new SeoAnalyzer()
.inputFolders(['public'])
.addRule(jsonLdRule)
.outputObject(console.log)
.run();
Eight lines. No library version bump needed.
What it doesn't do: There's no concept of a focus keyphrase. It checks structure — title length, canonical presence, meta tags — but has zero ability to tell you whether your keyword appears in the H2s or intro paragraph. And it's CommonJS only — no TypeScript types anywhere.
Performance: Numbers That Actually Matter
On a MacBook Pro M2, Node.js 20:
-
@power-seo/content-analysis: 2–5ms per check (synchronous, in-memory). Safe to run on every keystroke in a CMS editor. -
seo-analyzerwithinputHTMLString(): 40–60ms per check (async, DOM parse + rule execution). -
seo-analyzeron 200 HTML files viainputFolders(): 8–12 seconds total — fine for CI, never for real-time feedback.
Bundle sizes matter too. @power-seo/content-analysis is roughly 60KB minified + gzipped and is ESM, tree-shakable, edge-runtime safe. seo-analyzer is ~1MB and ships Node.js-only dependencies. Don't let it near a client bundle.
What I Actually Learned
- Neither library replaces the other. They solve different problems at different stages of your pipeline. Running both is not overkill — it's the complete picture.
- Keyphrase distribution is the gap most teams miss. Title and meta are easy to get right manually. Whether your keyword appears in the intro paragraph, at least one H2, and the image alt text? That needs tooling.
-
Structure checks catch silent failures at scale. A missing canonical tag on page 47 of 200 is invisible to manual review.
seo-analyzer -fl publicfinds it in 10 seconds. -
TypeScript matters more than you think. If you're in a strict TypeScript codebase, the lack of types in
seo-analyzermeans more friction than just an inconvenience — every call goes throughany.
If you want to explore the keyphrase-scoring approach, the Power SEO ecosystem (including @power-seo/content-analysis) is open source: Power SEO
What's Your Setup?
Most teams I've talked to have either no SEO checks in CI at all, or a single post-build URL scan. Very few have pre-merge content gates.
What does your SEO validation pipeline look like?
Are you running checks in CI, in the editor, both — or just hoping for the best and checking Search Console after the fact? I'm curious whether keyphrase-level validation is something teams actually want, or whether structural checks are enough for most use cases.
Drop your setup in the comments — I'd genuinely like to know.
Top comments (0)