SEO-First Next.js: The Complete Checklist for 2026

Photo by Unsplash

Photo by Unsplash
I've Lighthouse-audited a lot of Next.js sites and the same SEO mistakes appear on 80% of them: missing canonical URLs, no OG images, a sitemap that points to localhost, and LCP images that aren't preloaded. This checklist is everything I check before considering an App Router site SEO-ready. It's not theoretical — it's what I applied to my own portfolio site to get consistent 90+ Lighthouse scores and proper Google Search Console coverage.
Think of Next.js SEO as five stacked layers: (1) Metadata API — the basic title, description, canonical, and social tags. (2) Structured Data (JSON-LD) — machine-readable content for rich results. (3) Core Web Vitals — performance signals Google uses as ranking factors. (4) Sitemap and robots.txt — crawl guidance. (5) Image optimization — both for performance and for social sharing. All five need to be correct. Missing any one of them leaves ranking points on the table.
Next.js App Router has three ways to set metadata. The simplest is exporting a metadata object from a page — good for static pages like About or Contact. For dynamic pages (blog posts, product pages), use generateMetadata() which receives route params and can fetch data. For the root layout, set default metadata that all child pages inherit, then override specific fields in each page's generateMetadata. The hierarchy is layout → page, with page always winning on conflicts.
If your site is multilingual (mine is English and Indonesian), you need both canonical and alternates.languages in every page's metadata. The canonical points to the current locale's URL. The alternates.languages object maps locale codes to full URLs. This tells Google which language version to show which user, prevents duplicate content penalties, and enables Google's language-based search result targeting. Get this wrong and you'll see both /en/ and /id/ versions competing against each other in search rankings.
Next.js SEO Checklist — Signal Flow
┌─────────────────────────────────────────────┐
│ 1. Metadata API (app/layout.tsx) │
│ title · description · openGraph │
│ twitter · robots · canonical │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ 2. Structured Data (JSON-LD) │
│ Article · BreadcrumbList │
│ Person · WebSite · FAQPage │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ 3. Core Web Vitals │
│ LCP < 2.5s · FID < 100ms · CLS < 0.1 │
│ TTFB < 600ms · INP < 200ms │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ 4. Sitemap + robots.txt │
│ /sitemap.xml (dynamic) · /robots.txt │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ 5. Image Optimization │
│ next/image · WebP/AVIF · alt text │
│ priority on LCP image │
└─────────────────────────────────────────────┘Use generateStaticParams() for all blog post routes to force static generation. Statically generated pages have near-zero TTFB from Vercel's edge cache, which is the biggest single win for both Core Web Vitals and SEO. A dynamically rendered blog post page at 800ms TTFB versus a static one at 40ms is a meaningful difference in LCP.
JSON-LD structured data turns your content into machine-readable signals that Google uses for rich results — the article bylines, FAQ dropdowns, and breadcrumbs you see in search results. For a blog, implement Article schema on every post and BreadcrumbList on listing pages. For a portfolio, implement Person schema on the about page. Render JSON-LD as a script tag in your page component or layout. Next.js renders this server-side, so it's available to crawlers without JavaScript execution.
The app/sitemap.ts file in Next.js App Router returns a MetadataRoute.Sitemap array that Next.js serves as /sitemap.xml. For a bilingual site, include both locale variants of every URL. Set lastModified from your actual content's updatedAt date, not a hardcoded value. Set changeFrequency and priority appropriately — blog posts are 'monthly', the home page is 'weekly'. Submit the sitemap URL in Google Search Console and verify that all important URLs appear in the coverage report.
// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from "next"
import { getTranslations } from "next-intl/server"
type Props = { params: { slug: string; locale: string } }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, locale } = params
const post = await getBlogPost(slug)
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `https://www.matthewswong.com/${locale}/blog/${slug}`,
languages: {
"en": `https://www.matthewswong.com/en/blog/${slug}`,
"id": `https://www.matthewswong.com/id/blog/${slug}`,
},
},
openGraph: {
title: post.title,
description: post.excerpt,
url: `https://www.matthewswong.com/${locale}/blog/${slug}`,
siteName: "Matthews Wong",
images: [{ url: post.image, width: 1200, height: 630 }],
type: "article",
publishedTime: post.datePublished,
authors: ["Matthews Wong"],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.image],
},
}
}
// JSON-LD structured data component
export function ArticleJsonLd({ post }: { post: BlogPost }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
image: post.image,
author: {
"@type": "Person",
name: "Matthews Wong",
url: "https://www.matthewswong.com",
},
publisher: {
"@type": "Person",
name: "Matthews Wong",
},
datePublished: post.datePublished,
dateModified: post.dateModified,
mainEntityOfPage: {
"@type": "WebPage",
"@id": `https://www.matthewswong.com/blog/${post.slug}`,
},
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}
// app/sitemap.ts — dynamic sitemap
import { MetadataRoute } from "next"
import { blogPosts } from "@/app/lib/blogData"
export default function sitemap(): MetadataRoute.Sitemap {
const blogRoutes = blogPosts.flatMap((post) =>
["en", "id"].map((locale) => ({
url: `https://www.matthewswong.com/${locale}/blog/${post.slug}`,
lastModified: new Date(post.datePublished),
changeFrequency: "monthly" as const,
priority: 0.7,
}))
)
return [
{ url: "https://www.matthewswong.com", priority: 1.0 },
{ url: "https://www.matthewswong.com/en/blog", priority: 0.8 },
...blogRoutes,
]
}LCP (Largest Contentful Paint) is almost always your hero image. Add priority to the next/image component for the above-the-fold image on every page — this triggers a preload link tag. For CLS (Cumulative Layout Shift), always specify width and height on images and use aspect-ratio CSS for dynamically sized elements. For INP (Interaction to Next Paint), avoid long JavaScript tasks on the main thread — defer non-critical JS and use React's useTransition for expensive state updates.
Setting document.title or injecting meta tags from useEffect is invisible to crawlers. Next.js's Metadata API generates these tags server-side in the HTML head, which is what search engine bots read. If you're seeing missing titles in Search Console for pages that look correct in the browser, check whether the metadata is set via the Next.js API or client-side JavaScript.
Every image needs a descriptive alt attribute — not 'image' or the filename. Use the next/image component for all content images to get automatic WebP/AVIF serving and lazy loading. For OG images (the preview images when sharing on LinkedIn or X), use Next.js's next/og library to generate them dynamically — this way they're always fresh and match the current page content. OG images should be 1200x630px. Missing OG images mean your blog posts look blank when shared on social media.
Run Lighthouse in Incognito mode (to avoid extensions skewing results). Use Google's Rich Results Test to verify JSON-LD. Submit the sitemap in Search Console and wait for the coverage report. Use the URL Inspection tool in Search Console to see the page as Googlebot sees it — this reveals JS rendering issues. Check the 'Crawled — currently not indexed' report for any pages Google is choosing not to index and investigate why.