DEV Community

Cover image for My React App Was Invisible to Google - Here's How I Fixed It with an SEO API
Mitu Das
Mitu Das

Posted on • Originally published at ccbd.dev

My React App Was Invisible to Google - Here's How I Fixed It with an SEO API

I spent one day debugging why Google couldn't index my Next.js app. Lighthouse gave it a 100. The HTML looked fine. Turns out, I was rendering all my <meta> tags client-side - which means crawlers saw an empty <head> before JavaScript ran. The fix was 4 lines of code in generateMetadata(). But finding all the other SEO gaps? That needed an API.

This article is a working guide to SEO API integration for Next.js: how to programmatically audit, generate, and validate metadata so you're not playing whack-a-mole with Lighthouse every deploy.

Why "Just Add a Meta Tag" Isn't Enough Anymore

Modern SEO has three layers that are easy to miss when you're heads-down in product code:

  1. Static metadata - <title>, <meta name="description">, canonical URLs
  2. Dynamic/social metadata - Open Graph, Twitter Cards, per-page og:image
  3. Structured data - JSON-LD schemas that power rich results in Google Search

Most tutorials stop at layer one. But if a blog post shares badly on LinkedIn (wrong og:image) or your product page doesn't show a star rating in search results (missing JSON-LD), you're leaving clicks on the table.

Here's a systematic way to handle all three.

1. Fix the Root Cause: Server-Side Metadata in Next.js App Router

First, make sure your metadata is actually in the HTML that crawlers receive - not injected after hydration.

In the App Router (/app directory), use generateMetadata instead of touching <Head> in a component:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

type Props = { params: { slug: string } }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await fetchPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.ogImage, width: 1200, height: 630 }],
      type: 'article',
      publishedTime: post.publishedAt,
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      images: [post.ogImage],
    },
    alternates: {
      canonical: `https://yoursite.com/blog/${params.slug}`,
    },
  }
}

export default function BlogPost({ params }: Props) {
  // your component
}
Enter fullscreen mode Exit fullscreen mode

This runs on the server. Crawlers get the full <head> immediately. Problem one: solved.

Result: curl https://yoursite.com/blog/my-post | grep '<title>' now returns your actual title instead of a blank tag.

2. Automate Structured Data with JSON-LD

Google uses structured data (JSON-LD) to power rich results: breadcrumbs, article bylines, product ratings, FAQ dropdowns. Writing it by hand for every page type is error-prone. Here's a reusable component:

// components/JsonLd.tsx
type JsonLdProps = {
  data: Record<string, unknown>
}

export function JsonLd({ data }: JsonLdProps) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  )
}

// Usage in a blog post layout
import { JsonLd } from '@/components/JsonLd'

export default function BlogLayout({ post, children }) {
  const articleSchema = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    author: { '@type': 'Person', name: post.author },
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    image: post.ogImage,
  }

  return (
    <>
      <JsonLd data={articleSchema} />
      {children}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Test it immediately with Google's Rich Results Test. Paste your URL - if the JSON-LD is server-rendered, it'll parse it correctly.

Result: Your articles can start showing author and date information in Google Search results.

3. Integrate an SEO API to Audit at Scale

Manual checks work for a page or two. But what happens when you have 200 blog posts, 50 product pages, and a new deploy every day? You need programmatic auditing.

This is where an SEO API earns its keep. I started using @power-seo after finding it while searching for an npm package that could run structured SEO checks without spinning up a headless browser for every URL.

Install it:

npm install @power-seo
Enter fullscreen mode Exit fullscreen mode

Then hook it into a script you can run in CI:

// scripts/seo-audit.ts
import { createClient } from '@power-seo'

const client = createClient({ apiKey: process.env.POWER_SEO_API_KEY })

const pagesToAudit = [
  'https://yoursite.com/',
  'https://yoursite.com/blog',
  'https://yoursite.com/pricing',
]

async function runAudit() {
  for (const url of pagesToAudit) {
    const result = await client.audit(url)

    const issues = result.issues.filter(i => i.severity === 'error')
    if (issues.length > 0) {
      console.error(`\nāŒ SEO errors on ${url}:`)
      issues.forEach(i => console.error(`  - [${i.code}] ${i.message}`))
      process.exit(1) // Fail the CI build
    }

    console.log(`āœ… ${url} - score: ${result.score}/100`)
  }
}

runAudit()
Enter fullscreen mode Exit fullscreen mode

Add it to your package.json:

"scripts": {
  "seo:audit": "ts-node scripts/seo-audit.ts"
}
Enter fullscreen mode Exit fullscreen mode

And in your GitHub Actions workflow:

- name: SEO Audit
  run: npm run seo:audit
  env:
    POWER_SEO_API_KEY: ${{ secrets.POWER_SEO_API_KEY }}
Enter fullscreen mode Exit fullscreen mode

Now a missing <title> or broken canonical URL blocks the deploy - the same way a TypeScript error would.

Result: SEO regressions get caught in PRs, not three weeks later when your traffic drops.

4. Your React SEO Checklist - Runnable, Not Just Readable

This React SEO checklist isn't a PDF you'll forget about. It's a test file you keep in your repo:

// __tests__/seo.test.ts
import { JSDOM } from 'jsdom'
import { readFileSync } from 'fs'

// Run `next build && next export` first, then test the static HTML
function getHeadFrom(htmlPath: string) {
  const html = readFileSync(htmlPath, 'utf8')
  const dom = new JSDOM(html)
  return dom.window.document.head
}

describe('Homepage SEO', () => {
  const head = getHeadFrom('.next/server/app/index.html')

  it('has a non-empty <title>', () => {
    const title = head.querySelector('title')?.textContent
    expect(title).toBeTruthy()
    expect(title!.length).toBeLessThan(60)
  })

  it('has a meta description under 160 chars', () => {
    const desc = head.querySelector('meta[name="description"]')?.getAttribute('content')
    expect(desc).toBeTruthy()
    expect(desc!.length).toBeLessThan(160)
  })

  it('has og:image with absolute URL', () => {
    const ogImage = head.querySelector('meta[property="og:image"]')?.getAttribute('content')
    expect(ogImage).toMatch(/^https?:\/\//)
  })

  it('has canonical link', () => {
    const canonical = head.querySelector('link[rel="canonical"]')?.getAttribute('href')
    expect(canonical).toBeTruthy()
  })

  it('has JSON-LD structured data', () => {
    const jsonLd = head.querySelector('script[type="application/ld+json"]')
    expect(jsonLd).not.toBeNull()
    // Validate it's parseable
    expect(() => JSON.parse(jsonLd!.textContent!)).not.toThrow()
  })
})
Enter fullscreen mode Exit fullscreen mode

Run this in CI. If any assertion fails, the deploy stops.

What I Learned

  • Client-side <Head> is the #1 silent SEO killer in React apps. Always use generateMetadata in Next.js 13+ or getServerSideProps/getStaticProps + next/head in the Pages Router.
  • Structured data is underhyped. Most devs ignore JSON-LD until their competitors' results start showing star ratings and theirs don't.
  • SEO regressions are just bugs. Treating metadata as testable, CI-enforced output (not a manual checklist) is the mindset shift that makes this maintainable.
  • Automate the audit, not just the fix. Tools like @power-seo let you catch issues across hundreds of URLs without manual Lighthouse runs - worth it once your site grows past a handful of pages.

If you want to try this approach end-to-end, here's the full write-up with additional examples: https://ccbd.dev/blog/seo-api-integration-for-nextjs-the-complete-developer-guide.

What's Your SEO Setup?

Are you handling metadata manually, using a CMS that generates it for you, or do you have it in CI? I'm curious whether anyone has gone further - like auto-generating og:image screenshots per page with a Puppeteer lambda.

Drop your approach in the comments - genuinely interested in what's working at scale.

Top comments (0)