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:
-
Static metadata -
<title>,<meta name="description">, canonical URLs - Dynamic/social metadata - Open Graph, Twitter Cards, per-page og:image
- 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
}
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}
</>
)
}
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
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()
Add it to your package.json:
"scripts": {
"seo:audit": "ts-node scripts/seo-audit.ts"
}
And in your GitHub Actions workflow:
- name: SEO Audit
run: npm run seo:audit
env:
POWER_SEO_API_KEY: ${{ secrets.POWER_SEO_API_KEY }}
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()
})
})
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 usegenerateMetadatain Next.js 13+ orgetServerSideProps/getStaticProps+next/headin 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)