SEO Audit
Systematic checklist and automated testing patterns for evaluating a Next.js site's SEO health.
Quick Audit Commands
Run these to get an instant snapshot of SEO status:
# Check all pages have metadata exports
grep -r "export const metadata\|export async function generateMetadata" src/app --include="*.tsx" --include="*.ts" -l
# Find pages WITHOUT metadata
find src/app -name "page.tsx" | while read f; do
grep -qE "metadata|generateMetadata" "$f" || echo "MISSING metadata: $f"
done
# Check for sitemap and robots
ls src/app/sitemap.ts src/app/robots.ts 2>/dev/null || echo "Missing sitemap.ts or robots.ts"
# Check metadataBase
grep -r "metadataBase" src/app/layout.tsx || echo "NO metadataBase set"
# Find images without alt text
grep -rn "<img\|<Image" src/ --include="*.tsx" | grep -v "alt=" | head -20
# Check for H1 tags in pages
find src/app -name "page.tsx" | while read f; do
grep -qE "<h1|<H1" "$f" || echo "NO H1: $f"
done
# Check structured data
grep -r "application/ld+json" src/ --include="*.tsx" -l
Full SEO Audit Checklist
1. Crawlability & Indexability
| Check | How to Verify | Priority |
|---|---|---|
robots.ts exists and is correct |
Visit /robots.txt |
Critical |
sitemap.ts exists with all pages |
Visit /sitemap.xml, count URLs |
Critical |
| Sitemap submitted to Google Search Console | GSC → Sitemaps | Critical |
| No broken internal links (404s) | Run crawler or Lighthouse | High |
| No redirect chains (A→B→C) | Check next.config.ts redirects |
High |
| No orphan pages | Verify every page has inbound links | Medium |
| Clean URL structure (lowercase, hyphens) | Review route structure | Medium |
| Staging blocked from indexing | Check robots.ts non-production rules |
Critical |
2. Metadata & Social
| Check | How to Verify | Priority |
|---|---|---|
metadataBase set in root layout |
Grep for metadataBase |
Critical |
| Title template configured | Check root layout title.template |
Critical |
Every page has unique title |
Grep for metadata exports | Critical |
Every page has unique description |
Check metadata exports | Critical |
description length 150-160 chars |
Measure each page | High |
title length 50-60 chars |
Measure each page | High |
| Canonical URL on every page | Check alternates.canonical |
Critical |
| Open Graph tags present | View source, check og: tags |
High |
| OG image configured (1200x630) | Test with Facebook Debugger | High |
| Twitter Card configured | Test with Twitter Card Validator | Medium |
3. Structured Data
| Check | How to Verify | Priority |
|---|---|---|
| Organization schema on root layout | Check for JSON-LD in layout | High |
| BreadcrumbList on inner pages | Check for breadcrumb JSON-LD | Medium |
| Article schema on blog posts | Check blog page JSON-LD | High (if blog) |
| Product schema on product pages | Check product page JSON-LD | High (if e-commerce) |
| No FAQPage on non-gov/health site | Grep for FAQPage schema | High |
| Schema validates in Rich Results Test | Test live URLs | Critical |
schema-dts installed for type safety |
Check package.json |
Medium |
4. Performance
| Check | How to Verify | Priority |
|---|---|---|
| LCP < 2.5s | PageSpeed Insights | Critical |
| INP < 200ms | PageSpeed Insights (field data) | Critical |
| CLS < 0.1 | PageSpeed Insights | Critical |
LCP image has priority prop |
Check hero/banner Image component | High |
All images have sizes prop |
Grep for <Image without sizes |
High |
All images have width/height or fill |
Grep for missing dimensions | High |
next/font used (no external font requests) |
Check network tab, layout.tsx | High |
| AVIF/WebP enabled | Check next.config.ts images.formats |
Medium |
| No render-blocking third-party scripts | Check <Script> strategy props |
Medium |
5. Content
| Check | How to Verify | Priority |
|---|---|---|
| Single H1 per page | Grep for <h1 in pages |
Critical |
| Logical heading hierarchy (no skips) | Audit heading levels per page | High |
| All images have alt text | Grep for <Image without alt |
Critical |
| Alt text is descriptive (not "image") | Manual review | High |
| Descriptive internal link text | No "click here" anchors | Medium |
| Breadcrumb navigation present | Check inner pages | Medium |
6. Security & Mobile
| Check | How to Verify | Priority |
|---|---|---|
| HTTPS everywhere | Check production URL | Critical |
| Security headers configured | Check response headers | Medium |
| Mobile responsive | Test with Chrome DevTools | Critical |
| Touch targets >= 48px | Lighthouse accessibility audit | High |
| No horizontal scroll on mobile | Manual check | High |
| Viewport meta tag present | View source | Critical |
Automated Testing with Playwright
Create an SEO test file that validates critical SEO elements:
// tests/seo.spec.ts
import { test, expect } from '@playwright/test'
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
test.describe('SEO Fundamentals', () => {
test('homepage has correct meta tags', async ({ page }) => {
await page.goto(BASE_URL)
// Title
const title = await page.title()
expect(title).toBeTruthy()
expect(title.length).toBeLessThanOrEqual(60)
// Meta description
const description = page.locator('meta[name="description"]')
await expect(description).toHaveAttribute('content', /.{50,160}/)
// Canonical
const canonical = page.locator('link[rel="canonical"]')
await expect(canonical).toHaveAttribute('href', /https?:\/\//)
// OG tags
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute('content', /.+/)
await expect(page.locator('meta[property="og:description"]')).toHaveAttribute('content', /.+/)
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute('content', /https?:\/\//)
})
test('all pages have single H1', async ({ page }) => {
const pages = ['/', '/about', '/blog']
for (const path of pages) {
await page.goto(`${BASE_URL}${path}`)
const h1Count = await page.locator('h1').count()
expect(h1Count, `${path} should have exactly 1 H1`).toBe(1)
}
})
test('all images have alt text', async ({ page }) => {
await page.goto(BASE_URL)
const images = page.locator('img')
const count = await images.count()
for (let i = 0; i < count; i++) {
const alt = await images.nth(i).getAttribute('alt')
// alt can be empty string (decorative) but must exist
expect(alt, `Image ${i} missing alt attribute`).not.toBeNull()
}
})
test('sitemap.xml is valid', async ({ request }) => {
const response = await request.get(`${BASE_URL}/sitemap.xml`)
expect(response.status()).toBe(200)
const body = await response.text()
expect(body).toContain('<urlset')
expect(body).toContain('<url>')
expect(body).toContain('<loc>')
})
test('robots.txt is valid', async ({ request }) => {
const response = await request.get(`${BASE_URL}/robots.txt`)
expect(response.status()).toBe(200)
const body = await response.text()
expect(body).toContain('User-agent')
expect(body).toContain('Sitemap')
})
test('structured data is valid JSON', async ({ page }) => {
await page.goto(BASE_URL)
const scripts = page.locator('script[type="application/ld+json"]')
const count = await scripts.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const content = await scripts.nth(i).textContent()
expect(() => JSON.parse(content!), `JSON-LD block ${i} is invalid JSON`).not.toThrow()
const parsed = JSON.parse(content!)
expect(parsed['@context']).toBe('https://schema.org')
}
})
})
Lighthouse CI Integration
Add Lighthouse CI to your GitHub Actions:
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- uses: treosh/lighthouse-ci-action@v12
with:
urls: |
http://localhost:3000/
http://localhost:3000/blog
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true
Lighthouse budget file:
[{
"path": "/*",
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 },
{ "metric": "total-blocking-time", "budget": 200 }
],
"resourceCounts": [
{ "resourceType": "script", "budget": 10 },
{ "resourceType": "total", "budget": 50 }
]
}]
Full-Site Scan with Unlighthouse
For scanning every page on your site at once:
npx unlighthouse --site https://yourdomain.com
Unlighthouse crawls your entire site and runs Lighthouse on each page, producing a dashboard with aggregated results.
Audit Report Template
After running the audit, present results in this format:
## SEO Audit Report — [Site Name] — [Date]
### Critical Issues (fix immediately)
- [ ] Issue description — affected pages — recommended fix
### High Priority (fix this sprint)
- [ ] Issue description — affected pages — recommended fix
### Medium Priority (plan for next sprint)
- [ ] Issue description — affected pages — recommended fix
### Passing
- [x] Item that passed verification