Next.js SEO Audit: From Empty Shells to Search-Ready in 7 Steps
I recently shipped a full SEO overhaul on my personal site (built with Next.js 14, standalone Docker deployment). The starting point was grim — four core pages rendered as empty 'use client' shells invisible to crawlers. The end result: proper SSR, bilingual blog routes, structured data, favicons, sitemap, RSS, and search engine verification.
This post documents every step, so you can replicate it on your own Next.js site.
The Starting Point: What Was Wrong
A quick audit revealed problems across four priority levels:
| Priority | Issue | Impact |
|---|---|---|
| P0 | Homepage, About, Projects, Blog were 'use client' |
Crawlers see empty HTML — zero indexing |
| P1 | No custom 404 page | Soft 404s dilute crawl budget |
| P1 | Image optimization disabled (unoptimized: true) |
No WebP/AVIF, slower loads |
| P2 | Chinese blog content not in sitemap/RSS | Half the content invisible to engines |
| P2 | Person JSON-LD incomplete | Missing E-E-A-T signals |
| P3 | No web manifest, incomplete favicons | Poor PWA/mobile experience |
| P3 | No keywords meta, no Chinese description | Missing Baidu/Sogou signals |
Step 1: Migrate Client Components to Server Components (P0)
This was the single most impactful change. Four pages — /, /about, /projects, /blog — were 'use client' at the top level. Googlebot received empty <div id="__next"> shells with zero content.
Pattern:
- Remove
'use client'frompage.tsx— make it a Server Component - Extract all client logic into a sibling
ClientComponent.tsx(e.g.HomeClient.tsx) - Server Component passes data as props
- For i18n: server renders default language,
useLanguagehandles runtime switching
Example — Homepage:
// src/app/page.tsx (Server Component — no 'use client')
import HomeClient from './HomeClient';
export const metadata: Metadata = {
title: '...',
description: '...',
};
export default function HomePage() {
return <HomeClient />;
}
// src/app/HomeClient.tsx ('use client')
'use client';
// ... all interactive logic here
After migration, npm run build confirmed all four pages now show ○ (Static) — fully pre-rendered at build time.
Step 2: Custom 404 + Image Optimization (P1)
404 Page: Created src/app/not-found.tsx with branded UI and a "Back to Home" link. Proper 404 responses prevent soft-404 confusion.
Image Optimization: Removed images: { unoptimized: true } from next.config.js. Next.js now auto-generates WebP/AVIF for all <Image> components.
Step 3: Bilingual Blog Routes with Hreflang (P2)
The blog had English content in /blog/slug and Chinese in /content/blog/zh/, but the route structure only served /blog/*. Chinese posts were invisible to crawlers.
Implemented Option B — locale-prefixed routes:
/en/blog/my-post ← English
/zh/blog/my-post ← Chinese
/blog/my-post → 301 redirect (Moved Permanently) → /en/blog/my-post
Key files:
| File | Purpose |
|---|---|
src/app/[locale]/blog/[slug]/page.tsx |
Locale-aware blog post with generateMetadata (hreflang) |
src/app/[locale]/blog/page.tsx |
Locale-aware blog list |
src/lib/i18n.ts |
LOCALES, localePath(), blogAlternates() utilities |
src/middleware.ts |
/blog → /en/blog redirect |
The redirect middleware ensures backward compatibility:
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/blog' || pathname === '/blog/') {
return NextResponse.redirect(new URL('/en/blog', request.url), 301);
}
if (pathname.startsWith('/blog/')) {
const slug = pathname.replace('/blog/', '');
return NextResponse.redirect(new URL(`/en/blog/${slug}`, request.url), 301);
}
}
Sitemap updated to emit both locale variants (hreflang alternates are set per-page via generateMetadata):
// src/app/sitemap.ts
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const locales = ['en', 'zh']
const slugs = getAllSlugs()
return slugs.flatMap(slug =>
locales.map(locale => ({
url: `https://guancyxx.cn/${locale}/blog/${slug}`,
lastModified: new Date(),
}))
)
}
RSS feeds serve per-locale: /feed.xml (EN) and /zh/feed.xml (ZH).
Step 4: Complete Person JSON-LD (P2)
Enhanced the Person structured data with fields that strengthen E-E-A-T signals:
{
"@type": "Person",
"name": "Galen Guan",
"image": "https://guancyxx.cn/images/profile.jpg",
"alumniOf": { "@type": "Organization", "name": "..." },
"knowsLanguage": ["en", "zh"],
"sameAs": [
"https://github.com/guancyxx",
"https://www.linkedin.com/in/guancyxx"
]
}
Step 5: Web Manifest + Multi-Size Favicons (P3)
Generated favicons from source logo using Pillow:
from PIL import Image
img = Image.open('public/logo.png')
sizes = [(16,16), (32,32), (48,48)]
# favicon.ico, favicon-32x32.png, favicon-16x16.png,
# apple-touch-icon.png (180x180), android-chrome-192x192.png
Created public/site.webmanifest for PWA installability and updated metadata.icons in root layout.
Step 6: Keywords Meta + Bilingual Descriptions
Added <meta name="keywords"> to all pages with relevant terms for both Baidu and Google:
Galen Guan, 官小西, full-stack developer, AI product engineer,
React, Python, TypeScript, Next.js, open source,
AI开发, 全栈开发, 人工智能, 深度学习
Descriptions now end with a Chinese summary — critical for Baidu/Sogou which index the full Chinese text:
Building AI-powered products with 10+ years of full-stack expertise...
全栈开发与AI产品工程,10年+经验,开源贡献者。
Step 7: Search Engine Verification
- Google Search Console: Verified via DNS TXT record
- Bing Webmaster Tools: Added
msvalidate.01meta tag to root layout - Sitemap submission: Done via GSC interface
Results
| Metric | Before | After |
|---|---|---|
| Crawlable pages | 1 (404) | 10+ (all SSR) |
| Blog routes | 3 (EN only) | 6 (EN + ZH with hreflang) |
| Structured data | Person only | Person + BlogPosting |
| Favicons | 1 (logo.png) | 5 (ico + 4 PNG sizes) |
| RSS feeds | 1 (EN) | 2 (EN + ZH) |
| Keywords meta | None | Bilingual EN+ZH |
| Search verification | None | Google + Bing |
Bonus:
src/app/robots.ts— A properly configured robots.txt that disallows/api/and/_next/while linking to the sitemap. Small detail, but signals professionalism to crawlers.
Key Takeaways
- SSR first — the single biggest SEO win for any SPA/CSR site
- Hreflang for multilingual — don't skip
alternates.languagesin metadata - Bilingual descriptions — essential for CJK search engines (Baidu, Sogou, Naver)
- JSON-LD completeness —
image,alumniOf,sameAsall matter for E-E-A-T - Build verification — always run
npm run buildand confirm pages are○ (Static)
The full audit checklist lives in SEO.md at the project root. If you're running a Next.js personal site, I'd recommend going through the same audit process before launching — it takes a day but saves months of wondering why Google can't find you.