I once shipped a React app whose Google result still said "Vite + React App". The content was great. Crawlers just never saw a real title, description, or canonical URL.

Most JavaScript SEO bugs aren't mysterious. They're missing basics that nobody tests. In this post I'll show how to check what crawlers actually receive, fix metadata and structured data, and gate your CI on content quality. I'll use a few open source packages along the way.

Disclosure: I'm part of the team behind @power-seo, which appears in the examples. The techniques work with any library.

1. First, see what a crawler sees

Stop guessing and fetch the raw HTML. This script flags what's missing before JavaScript runs (Node 18+, no dependencies):

// seo-check.mjs const url = process.argv[2] ?? 'http://localhost:3000'; const html = await (await fetch(url)).text(); const checks = { title: /<title>[^<]{10,}<\/title>/i, description: /<meta[^>]+name=["']description["']/i, canonical: /<link[^>]+rel=["']canonical["']/i, jsonLd: /<script[^>]+application\/ld\+json/i, }; let failed = 0; for (const [name, re] of Object.entries(checks)) { const ok = re.test(html); if (!ok) failed++; console.log(`${ok ? '✅' : '❌'} ${name}`); } process.exit(failed ? 1 : 0); 

Run node seo-check.mjs https://yoursite.com. On a client-rendered SPA, expect a wall of ❌. That's your baseline, and the non-zero exit code means you can reuse it in CI.

2. Fix metadata at the source

If you're on the Next.js App Router, the fix is server-rendered metadata, so tags exist in the initial HTML. Here's a site-wide default using @power-seo/meta, a small helper that returns Next-compatible metadata:

npm install @power-seo/meta @power-seo/schema 
// app/layout.tsx import { createMetadata } from '@power-seo/meta'; export const metadata = createMetadata({ title: { template: '%s | My Site', default: 'Welcome to My Site' }, description: 'Your site description here', metadataBase: new URL('https://example.com'), openGraph: { type: 'website', url: 'https://example.com', siteName: 'My Site' }, }); 

Per-page metadata follows the same shape:

// app/blog/[slug]/page.tsx export function generateMetadata({ params }) { return createMetadata({ title: 'My Blog Post', description: 'A great article about SEO.', canonical: `https://example.com/blog/${params.slug}`, openGraph: { type: 'article', publishedTime: '2026-01-15T00:00:00Z' }, }); } 

Re-run the checker and title, description, and canonical should flip to ✅.

For a pure React SPA, a component like <SEO /> from @power-seo/react (similar in spirit to React Helmet) manages tags. Be aware that client-side tags only help crawlers that execute JavaScript. For anything that matters, prerender or server-render.

3. Add structured data you can trust

JSON-LD helps search engines understand your pages, and it's easy to break. Hand-written blobs drift out of sync, and unescaped < characters in titles can break out of the script tag. A builder handles both:

import { article, toJsonLdString } from '@power-seo/schema'; export default function BlogPage() { const schema = article({ headline: 'My Blog Post', datePublished: '2026-01-15', author: { name: 'Jane Doe' }, }); return ( <> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: toJsonLdString(schema) }} /> <article><h1>My Blog Post</h1></article> </> ); } 

If you'd rather go without a library, JSON.stringify(obj).replace(/</g, '\\u003c') covers the escaping. Whichever route you pick, validate the output in Google's Rich Results Test before shipping.

4. Make SEO a CI check, not a quarterly panic

Two small automations catch most regressions. First, generate your sitemap from the same data source as your pages:

// app/sitemap.ts import { toNextSitemap } from '@power-seo/sitemap/next'; export default async function sitemap() { const urls = await fetchAllUrls(); // your CMS or database call return toNextSitemap(urls); } 

Second, fail the build when on-page content scores badly. This one uses @power-seo/content-analysis:

// scripts/content-gate.ts import { analyzeContent } from '@power-seo/content-analysis'; const result = analyzeContent({ title: 'My Article', metaDescription: 'Description', content: '<h1>My Article</h1><p>Content...</p>', focusKeyphrase: 'my keyword', }); console.log(`SEO Score: ${result.score}/${result.maxScore}`); if (result.score / result.maxScore < 0.7) process.exit(1); 

The 70% threshold is arbitrary, so tune it to your content. Combined with seo-check.mjs from step one, regressions now show up in the PR instead of in Search Console two months later.

What I learned

  • Test the raw HTML, not the browser: What you see in DevTools isn't what a crawler gets first.
  • Metadata belongs on the server: Server-rendered tags beat client-injected ones for crawlers that don't run JavaScript.
  • Automate the boring checks: A 20-line script in CI beats any "remember to check SEO" doc.
  • Tools don't replace fundamentals: A toolkit handles implementation, not keyword research or backlinks. I wrote up my own before/after Search Console numbers, with the caveats, in this case study. It shows a correlation, not proof.

The packages are on npm and are modular, so you install only what you need.

If you want to try this approach, here's the repo: https://github.com/CyberCraftBD/power-seo

Your turn

What's the most embarrassing SEO bug you've shipped to production? Mine was the "Vite + React App" title. And do you check SEO in CI, or does it still live in someone's head? I'd love to compare setups in the comments.