I spent 6 hours debugging why Google Search Console showed zero impressions for a landing page I'd just shipped. The culprit? A missing <title> tag because a lazy useEffect was overwriting it with an empty string on hydration. The fix was 4 lines of code. The wasted Friday afternoon was entirely avoidable.
If you're building React apps and treating SEO as an afterthought, this article is for you. I'll show you how to wire a real SEO content checker into your workflow so you catch these issues before they ever reach production.
Why React Apps Have an SEO Blind Spot
Server-rendered frameworks like Next.js have improved things, but a huge chunk of React apps are still SPAs that rely on client-side rendering. Crawlers have gotten smarter, but they're not perfect and even in SSR setups, meta tags get silently dropped, duplicated, or overwritten by nested component trees.
The bugs are subtle:
- A
<Helmet>component deep in a route component overrides the parent's<title> - Dynamic OG images point to
undefinedbecause the data hadn't loaded yet - Description tags hit 300+ characters because someone copy-pasted a paragraph
You don't notice these locally because you can read the page. Google notices and demotes you.
The fix isn't better discipline. It's automated checking.
Step 1: Audit Your Existing Pages With a Node Script
Before adding tooling, you need a baseline. Here's a dead-simple script that crawls your rendered HTML and checks for common SEO problems:
// scripts/seo-audit.ts
import { JSDOM } from "jsdom";
import fetch from "node-fetch";
interface SEOReport {
url: string;
title: string | null;
titleLength: number;
description: string | null;
descriptionLength: number;
issues: string[];
}
async function auditPage(url: string): Promise<SEOReport> {
const res = await fetch(url);
const html = await res.text();
const dom = new JSDOM(html);
const doc = dom.window.document;
const title = doc.querySelector("title")?.textContent ?? null;
const descEl = doc.querySelector('meta[name="description"]');
const description = descEl?.getAttribute("content") ?? null;
const issues: string[] = [];
if (!title) issues.push("Missing <title> tag");
else if (title.length < 30) issues.push(`Title too short (${title.length} chars)`);
else if (title.length > 60) issues.push(`Title too long (${title.length} chars)`);
if (!description) issues.push("Missing meta description");
else if (description.length < 70) issues.push(`Description too short (${description.length} chars)`);
else if (description.length > 160) issues.push(`Description too long (${description.length} chars)`);
if (!doc.querySelector('meta[property="og:image"]'))
issues.push("Missing og:image");
if (!doc.querySelector("h1"))
issues.push("No <h1> tag found");
return {
url,
title,
titleLength: title?.length ?? 0,
description,
descriptionLength: description?.length ?? 0,
issues,
};
}
async function main() {
const urls = [
"http://localhost:3000",
"http://localhost:3000/about",
"http://localhost:3000/blog",
];
for (const url of urls) {
const report = await auditPage(url);
console.log(`\nš ${report.url}`);
if (report.issues.length === 0) {
console.log(" ā
All checks passed");
} else {
report.issues.forEach((issue) => console.log(` ā ${issue}`));
}
}
}
main();
Run it against your local dev server:
npx ts-node scripts/seo-audit.ts
Result: You get a per-page issue list in 10 seconds. This alone will surface embarrassing problems you've been shipping silently.
Step 2: Add a React SEO Checklist as a Dev-Mode Overlay
The audit script is great for CI, but during development you want instant feedback. Here's a lightweight component that renders an SEO checklist overlay in dev mode no page reload required.
// components/SEODebugOverlay.tsx
import { useEffect, useState } from "react";
interface Check {
label: string;
pass: boolean;
detail?: string;
}
function runChecks(): Check[] {
const title = document.title;
const descEl = document.querySelector<HTMLMetaElement>(
'meta[name="description"]'
);
const desc = descEl?.content ?? "";
const ogImage = document.querySelector('meta[property="og:image"]');
const h1s = document.querySelectorAll("h1");
const canonical = document.querySelector('link[rel="canonical"]');
return [
{
label: "Title length (30ā60 chars)",
pass: title.length >= 30 && title.length <= 60,
detail: `${title.length} chars: "${title.slice(0, 40)}ā¦"`,
},
{
label: "Meta description (70ā160 chars)",
pass: desc.length >= 70 && desc.length <= 160,
detail: desc ? `${desc.length} chars` : "Missing",
},
{
label: "OG image present",
pass: !!ogImage,
},
{
label: "Exactly one <h1>",
pass: h1s.length === 1,
detail: `Found ${h1s.length}`,
},
{
label: "Canonical URL set",
pass: !!canonical,
},
];
}
export function SEODebugOverlay() {
const [checks, setChecks] = useState<Check[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
setChecks(runChecks());
}, []);
if (process.env.NODE_ENV !== "development") return null;
const failCount = checks.filter((c) => !c.pass).length;
return (
<div style={{ position: "fixed", bottom: 16, right: 16, zIndex: 9999 }}>
<button
onClick={() => setOpen((o) => !o)}
style={{
background: failCount > 0 ? "#ef4444" : "#22c55e",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "6px 12px",
cursor: "pointer",
fontFamily: "monospace",
fontSize: 13,
}}
>
SEO {failCount > 0 ? `ā ${failCount} issues` : "ā"}
</button>
{open && (
<div
style={{
background: "#1e1e2e",
color: "#cdd6f4",
borderRadius: 8,
padding: 16,
marginTop: 8,
width: 320,
fontFamily: "monospace",
fontSize: 12,
}}
>
{checks.map((c) => (
<div key={c.label} style={{ marginBottom: 8 }}>
<span style={{ color: c.pass ? "#a6e3a1" : "#f38ba8" }}>
{c.pass ? "ā" : "ā"}
</span>{" "}
{c.label}
{c.detail && (
<div style={{ color: "#6c7086", marginLeft: 16 }}>
{c.detail}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
Drop <SEODebugOverlay /> into your root layout. It shows a green/red badge in the corner click it to see the full React SEO checklist without leaving the page.
Step 3: Pull It Into CI With a Proper Checker Package
The overlay and script are solid for most projects. When you're managing multiple routes or content-heavy pages (think: blogs, product pages, landing page variants), you want something more structured.
This is where @power-seo fits naturally. It's a TypeScript-first SEO content checker tool that runs programmatically against your rendered output no headless browser required. I started using it after the setup above got repetitive across three client projects.
npm install @power-seo
// scripts/check-seo.ts
import { checkSEO } from "@power-seo";
const result = await checkSEO({
html: await fetch("http://localhost:3000/blog/my-post").then((r) => r.text()),
url: "https://yourdomain.com/blog/my-post",
});
if (!result.pass) {
console.error("SEO issues found:");
result.issues.forEach((issue) => {
console.error(` [${issue.severity}] ${issue.message}`);
});
process.exit(1); // Fail the CI build
}
What I like about it: the severity field. Not every SEO issue is a blocker a missing OG image is a warning; a missing <title> is a critical error. That distinction matters when you're wiring this into a CI gate and you don't want to block deploys over minor things. The deeper write-up of how it handles edge cases (duplicate canonicals, hreflang conflicts, structured data validation) is on ccbd.dev.
What I Actually Learned
SEO bugs are invisible until they're expensive. Add the debug overlay from day one it costs you 20 minutes to set up and saves hours of Googling "why isn't my page indexed."
Test rendered HTML, not your JSX. Component props look fine. The DOM at runtime is where things break. Always audit the actual output.
The React SEO checklist you need is short. Title (30ā60), description (70ā160), one
<h1>, OG image, canonical. Everything else is optimization. Get these five right first.Fail builds on critical SEO issues. Treat a missing
<title>like a missingreturnstatement. It's a bug.
What's your SEO workflow?
Do you check meta tags manually, use a browser extension, or do you have something automated? I'm curious whether anyone has integrated SEO auditing into their preview deployment pipeline (Vercel/Netlify preview URLs specifically) that's the next thing I'm trying to solve. Drop your setup in the comments.
Top comments (0)