Web
- Next.js
- SEO
- Technical SEO
- React
- Core Web Vitals
Next.js App Router SEO: A Technical Field Guide for 2026
Next.js App Router SEO: metadata, generateMetadata, JSON-LD, sitemaps, robots, next/image, next/font, and pitfalls that cost crawl budget and rankings.
The App Router moves metadata into a typed API tied to your route tree. Server Components are the default, so the first HTML response — not a client-side fetch after hydration — is what crawlers and social preview scrapers see first.
Most production sites pass a Lighthouse run but still ship wrong canonicals, relative Open Graph URLs, lazy-loaded LCP images, and indexable copy trapped inside 'use client' boundaries. This guide covers what we implement and audit on Next.js sites: metadata, JSON-LD in server components, sitemaps and robots, image and font discipline for Core Web Vitals, and the failures that do not show up in a marketing checklist.
It targets Next.js 16 with the App Router (patterns apply to 14.x and 15.x as well).
How the App Router changes SEO
The shift is simple to state and deep to respect: Server Components are the default. They render to HTML on the server; that HTML is what conservative crawlers see first, and what aggressive ones trust when comparing render snapshots.
Why that matters in practice:
- Primary content in Server Components is present in the initial HTML — the baseline for reliable indexing.
- The Metadata API (
metadata/generateMetadata) emits<head>on the server — no client-side title swap, no hydration race for your canonical. 'use client'is a boundary, not a badge — anything inside it may hydrate later; treat indexable copy, headings, and JSON-LD as server-owned unless you have verified the client path.
Rule of thumb: headings, body copy, and structured data live in server components. Reserve 'use client' for interactivity — forms, motion, maps, charts — the machinery that should not own your story in HTML.
Metadata API: Complete Reference
Static metadata (layout or page)
Export a metadata object from any page.tsx or layout.tsx:
import type { Metadata } from "next"
export const metadata: Metadata = {
// Title — shown in browser tab and search result snippet
title: "Page Title — Site Name",
// Or use a title template in root layout:
// title: { template: "%s | Site Name", default: "Site Name" }
// Meta description — shown in search snippets (~155 chars max)
description: "Concise page description under 155 characters. Specific, no filler.",
// Canonical URL — tells crawlers which URL is the authoritative version
// metadataBase must be set in root layout for this to resolve correctly
alternates: {
canonical: "/services/web-development",
},
// Open Graph — controls how the page looks when shared on social media
openGraph: {
title: "Page Title",
description: "OG description (can differ from meta description)",
url: "https://varnox.io/services/web-development",
siteName: "Varnox",
images: [
{
url: "/opengraph-image", // Can be a route: app/opengraph-image.tsx
width: 1200,
height: 630,
alt: "Descriptive alt text for the OG image",
},
],
locale: "en_US",
type: "website", // or "article" for blog posts
},
// Twitter Card
twitter: {
card: "summary_large_image",
title: "Page Title",
description: "Twitter description",
images: ["/opengraph-image"],
// creator: "@yourhandle", // if you have a Twitter account
},
// Robots directive — controls indexation
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
}
Dynamic metadata with generateMetadata
Use generateMetadata for routes where metadata depends on data (blog posts, product pages, service pages):
import type { Metadata, ResolvingMetadata } from "next"
import { getPostBySlug } from "@/lib/content"
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) return { title: "Article" }
const previousImages = (await parent).openGraph?.images || []
return {
title: post.title,
description: post.description,
alternates: {
canonical: `/blog/${slug}`,
},
openGraph: {
title: post.title,
description: post.description,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt ?? post.publishedAt,
authors: [post.author],
images: [
{
url: post.ogImage ?? "/opengraph-image",
width: 1200,
height: 630,
alt: post.title,
},
...previousImages,
],
},
}
}
metadataBase — required for absolute URLs
Set this once in app/layout.tsx. Without it, alternates.canonical and OG url fields are relative, which breaks structured data validation:
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://varnox.io"),
title: {
template: "%s | Varnox",
default: "Varnox — IT, web, and infrastructure",
},
// ...
}
Title and Description Best Practices
Titles:
- 50–60 characters (Google truncates at ~600px display width)
- Format:
Primary Keyword — Brand NameorBrand Name | Page Name - Unique per page — duplicate titles tell crawlers the pages are the same
- Use
title.templatein layout to avoid manually appending the brand name everywhere
Descriptions:
- 140–155 characters
- Google may rewrite them (uses surrounding content), but a good description improves click-through rate
- Include the primary keyword naturally
- Avoid: "Welcome to our website", "Click here to learn more"
- Good: "WireGuard VPN setup for distributed teams — server config, client profiles, split tunneling, and documented key rotation. Remote delivery."
OG Image Generation with opengraph-image.tsx
Statically served OG images from /public get stale. Dynamic OG images via the App Router's image generation route stay current and can include page-specific content.
Create app/opengraph-image.tsx:
import { ImageResponse } from "next/og"
export const runtime = "edge"
export const alt = "Varnox"
export const size = { width: 1200, height: 630 }
export const contentType = "image/png"
export default async function Image() {
return new ImageResponse(
(
<div
style={{
background: "#09090b",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
padding: "80px",
}}
>
<div style={{ color: "#22d3ee", fontSize: 24, fontWeight: 600 }}>
Varnox
</div>
<div style={{ color: "white", fontSize: 60, fontWeight: 700, marginTop: 20, lineHeight: 1.1 }}>
Default OG title
</div>
</div>
),
{ ...size }
)
}
For dynamic OG images (per blog post or service), use app/blog/[slug]/opengraph-image.tsx:
import { ImageResponse } from "next/og"
import { getPostBySlug } from "@/lib/content"
export const runtime = "edge"
export const size = { width: 1200, height: 630 }
type Props = { params: Promise<{ slug: string }> }
export default async function Image({ params }: Props) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
return new ImageResponse(<div style={{ color: "#fff" }}>Not found</div>, { ...size })
}
return new ImageResponse(
(
<div
style={{
background: "#020617",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "72px",
}}
>
<div style={{ color: "white", fontSize: 52, fontWeight: 700, lineHeight: 1.15 }}>
{post.title}
</div>
<div style={{ color: "#22d3ee", marginTop: 24, fontSize: 22 }}>
{post.category} · {new Date(post.publishedAt).getFullYear()}
</div>
</div>
),
{ ...size }
)
}
Sitemap Generation (app/sitemap.ts)
The sitemap tells search engines which URLs exist, their priority, and when they were last updated.
import type { MetadataRoute } from "next"
import { getAllPosts } from "@/lib/content"
import { SERVICE_SLUGS } from "@/lib/service-pages"
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://varnox.io"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const staticRoutes: MetadataRoute.Sitemap = [
{ url: `${BASE}/`, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${BASE}/services`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.9 },
{ url: `${BASE}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
{ url: `${BASE}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE}/blog`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 },
]
const services: MetadataRoute.Sitemap = SERVICE_SLUGS.map((slug) => ({
url: `${BASE}/services/${slug}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.85,
}))
const blogRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${BASE}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt ?? post.publishedAt),
changeFrequency: "monthly" as const,
priority: 0.6,
}))
return [...staticRoutes, ...services, ...blogRoutes]
}
Verify the sitemap is live: https://varnox.io/sitemap.xml (or your production origin).
Common sitemap mistakes:
- Including
noindexpages (confuses crawlers — they can't crawl a URL the sitemap suggests is important) - Stale
lastModifieddates (use the actual file modification time for static pages; database timestamp for dynamic) - Missing pages or including redirect URLs (sitemap should list canonical, final URLs only)
- Not submitting to Google Search Console (do this after verification)
Robots.txt (app/robots.ts)
import type { MetadataRoute } from "next"
const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://varnox.io"
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/_next/", "/admin/"],
},
// Optional: separate rules for AI crawlers — policy is yours; document it.
// { userAgent: "GPTBot", disallow: "/" },
],
sitemap: `${BASE}/sitemap.xml`,
}
}
Common robots.txt mistakes:
- Disallowing
/accidentally (blocks all crawling) - Disallowing CSS or JavaScript files (prevents Googlebot from rendering pages)
- Having
Disallow: /for Googlebot in a staging environment and forgetting to remove it before launch - Not pointing to the sitemap
Rendering Modes and SEO Implications
App Router components are server components by default — static by default, dynamic when needed.
Static (default)
// page.tsx — statically generated at build time
export default function Page() {
return <div>This page is statically generated</div>
}
Fastest for crawlers and users. Use for pages that don't change per-request (home, services, about, contact).
ISR (Incremental Static Regeneration)
// Revalidate every hour — cached, regenerated in background
export const revalidate = 3600
export default async function BlogPage() {
const posts = await getPosts() // Cached, re-fetched every hour
return <PostList posts={posts} />
}
Use for blog listings, product pages, anything that changes occasionally.
Dynamic (per-request)
// Force dynamic rendering — no caching
export const dynamic = "force-dynamic"
export default async function Dashboard() {
const user = await getCurrentUser() // Per-request auth
return <Dashboard user={user} />
}
Use for authenticated pages, real-time data. Avoid on public SEO-important pages — crawlers still see HTML, but performance suffers.
Server Components vs Client Components: the SEO boundary
// Good: content in server component — crawlers see it immediately
export default async function ServicePage() {
const service = await getService("web-development")
return (
<main>
<h1>{service.title}</h1>
<p>{service.description}</p>
{/* Client component only for the interactive form */}
<ContactForm />
</main>
)
}
// Bad: all content inside a client component
"use client"
export default function ServicePage() {
const [service, setService] = useState(null)
useEffect(() => {
fetch("/api/service/web-development").then(r => r.json()).then(setService)
}, [])
// Crawlers may see an empty page until JS loads and fetches
return <main>{service?.description}</main>
}
JSON-LD Structured Data in Server Components
Inject JSON-LD directly in server components via a <script> tag. This is cleaner than library-based approaches and works correctly with Next.js streaming.
// In any server component (layout.tsx or page.tsx)
export default function HomePage() {
const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
"name": "Varnox",
"url": "https://varnox.io",
"logo": "https://varnox.io/varnox_dark_default.svg",
"email": "[email protected]",
"sameAs": [
"https://www.linkedin.com/company/varnoxio/",
"https://github.com/varnoxio"
]
}
const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
"url": "https://varnox.io",
"name": "Varnox",
// Only add SearchAction if your site has working on-site search at this URL:
// "potentialAction": { "@type": "SearchAction", ... }
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema) }}
/>
{/* page content */}
</>
)
}
Article schema for blog posts
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"description": post.excerpt,
"datePublished": post.publishedAt,
"dateModified": post.updatedAt ?? post.publishedAt,
"author": {
"@type": "Organization",
"name": post.author,
"url": "https://varnox.io/about"
},
"publisher": {
"@type": "Organization",
"name": "Varnox",
"logo": {
"@type": "ImageObject",
"url": "https://varnox.io/varnox_dark_default.svg"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": `https://varnox.io/blog/${post.slug}`
}
}
FAQPage schema (on-page Q&A — not Google FAQ rich results)
Google discontinued FAQ rich results for most websites in August 2023. As of 2026, expandable FAQ snippets in Google Search are limited to authoritative government and health sources. Commercial and service sites should not expect FAQ dropdowns — implement FAQPage only when questions and answers are visible on the page, for readers, Bing, and machine-readability.
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map((faq) => ({
"@type": "Question",
"name": faq.question,
"acceptedAnswer": {
"@type": "Answer",
"text": faq.answer
}
}))
}
Validate with Rich Results Test — a “valid” FAQPage does not mean Google will show an FAQ accordion. That is expected for business sites in 2026.
BreadcrumbList schema
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://varnox.io"
},
{
"@type": "ListItem",
"position": 2,
"name": "Services",
"item": "https://varnox.io/services"
},
{
"@type": "ListItem",
"position": 3,
"name": "Web Development"
// Last item: no "item" property
}
]
}
Validate all schemas at search.google.com/test/rich-results before deploying.
next/image Deep Dive for Core Web Vitals
Images cause more LCP (Largest Contentful Paint) and CLS (Cumulative Layout Shift) issues than any other element. next/image fixes most of them automatically if used correctly.
The priority prop — most important thing to get right
The LCP element is usually the largest image above the fold (hero image) or a text heading. If it's an image, priority must be set:
import Image from "next/image"
// Hero image — add priority to preload it
<Image
src="/hero.jpg"
alt="Hero image description"
width={1920}
height={1080}
priority // Adds rel=preload link in <head>; removes lazy loading
sizes="100vw"
/>
Without priority, the image is lazy-loaded — it won't start downloading until it's in the viewport, making it the LCP element that loads late.
Rule: The first image visible above the fold gets priority. All other images do not.
The sizes prop — prevents oversized image downloads
// Without sizes — browser downloads the full-width image even on mobile
<Image src="/photo.jpg" alt="..." width={800} height={400} />
// With sizes — browser downloads the appropriate size
<Image
src="/photo.jpg"
alt="..."
width={800}
height={400}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
sizes tells the browser what fraction of the viewport the image occupies at different breakpoints. Next.js generates a srcset based on this, and the browser picks the appropriate size.
For full-width images: sizes="100vw"
For half-width images: sizes="(max-width: 768px) 100vw, 50vw"
For images in a 3-column grid: sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
Preventing CLS with explicit dimensions
CLS (Cumulative Layout Shift) occurs when images load and push content down. next/image requires width and height props, which it uses to set aspect-ratio on the image — the browser reserves space before the image loads.
For fill layout (image fills its container):
<div style={{ position: "relative", height: "400px" }}>
<Image
src="/hero.jpg"
alt="Hero"
fill
style={{ objectFit: "cover" }}
sizes="100vw"
/>
</div>
Image formats
Next.js automatically serves WebP (and AVIF if configured) to supporting browsers:
// next.config.js
module.exports = {
images: {
formats: ["image/avif", "image/webp"],
// AVIF offers 50% smaller files than WebP at same quality
// but encoding is slower — only enable if you have build time budget
},
}
Blur placeholder for perceived performance
import Image from "next/image"
import heroImg from "@/public/hero.jpg"
// For local images — Next.js generates the blur data URL automatically
<Image
src={heroImg}
alt="Hero"
placeholder="blur"
priority
sizes="100vw"
/>
// For remote images — provide blurDataURL manually
<Image
src="https://cdn.example.com/image.jpg"
alt="..."
width={800}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSk..."
/>
next/font for Zero-CLS Font Loading
Custom fonts loaded with a <link> tag cause CLS (layout shift as the fallback font is replaced) and FCP delays. next/font eliminates both.
// app/layout.tsx
import { Inter, Syne } from "next/font/google"
const inter = Inter({
subsets: ["latin"],
display: "swap", // Show text immediately with fallback, swap to custom font
variable: "--font-inter",
})
const syne = Syne({
subsets: ["latin"],
weight: ["400", "600", "700", "800"],
display: "optional", // Only use custom font if loaded within a timeout
variable: "--font-syne",
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${syne.variable}`}>
<body style={{ fontFamily: "var(--font-inter, sans-serif)" }}>
{children}
</body>
</html>
)
}
display: "optional" vs display: "swap":
swap— shows fallback immediately, swaps to custom font when loaded. Can cause CLS if the custom font is much larger/smaller than the fallback.optional— uses custom font only if it loads within a short browser-defined timeout. Zero CLS. Recommended for non-critical display fonts (headings).
next/font self-hosts the font files alongside your application and adds them to the <link rel=preload> list — no external request to fonts.googleapis.com, which is a network call that delays rendering.
generateStaticParams for SSG Dynamic Routes
For dynamic routes like /blog/[slug] or /services/[slug], tell Next.js which params to pre-render at build time:
// app/blog/[slug]/page.tsx
import { getAllPosts, getPostBySlug } from "@/lib/content"
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
type Props = { params: Promise<{ slug: string }> }
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPostBySlug(slug)
// ...
}
Without generateStaticParams, dynamic routes render on-demand (SSR). For public blog posts, service pages, and any content that doesn't change per-user, static generation is always faster and better for SEO.
Canonical URLs: Common Mistakes
Canonical URLs tell crawlers which URL is the "real" one when the same content is accessible at multiple URLs.
The trailing slash problem: Are yourdomain.com/blog and yourdomain.com/blog/ the same page? To crawlers, they're different URLs — duplicate content. Pick one and redirect the other:
// next.config.js
module.exports = {
trailingSlash: false, // Redirects /blog/ → /blog
// or trailingSlash: true — redirects /blog → /blog/
}
Set a canonical that matches your chosen format:
alternates: {
canonical: "/blog/my-post", // No trailing slash
}
WWW vs non-WWW: Redirect www.yourdomain.com to yourdomain.com (or vice versa) at the DNS/server level. Set metadataBase to the canonical domain.
Pagination: Google no longer supports rel="prev"/"next" for pagination SEO. Each paginated page should have its own unique <title> and <meta description>, and a self-referencing canonical (not pointing to page 1).
next.config.js: Redirects, Headers, and Compression
Redirects
// next.config.js
module.exports = {
async redirects() {
return [
// 301 Permanent — passes link equity (use for URL restructures)
{
source: "/old-services/:slug",
destination: "/services/:slug",
permanent: true, // 301
},
// 302 Temporary — does not pass link equity
{
source: "/promo",
destination: "/services",
permanent: false, // 302
},
]
},
}
Avoid redirect chains (A → B → C). Crawlers follow up to 5 redirects, but each hop dilutes link equity and slows crawling.
Security and caching headers
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains",
},
],
},
// Cache static assets aggressively
{
source: "/_next/static/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
]
},
Compression
Compression is best handled at the CDN or reverse proxy (gzip and Brotli). Next.js may still compress responses when compress: true is set in next.config, but do not rely on that alone in production — configure your edge or NGINX:
# NGINX — gzip (built-in)
gzip on;
gzip_types text/html text/css application/javascript application/json;
gzip_comp_level 6;
# Brotli requires the ngx_brotli module (not included in stock NGINX packages).
# On Ubuntu: apt install libnginx-mod-brotli, then:
# brotli on;
# brotli_comp_level 6;
# brotli_types text/html text/css application/javascript application/json;
Optional fallback in next.config:
module.exports = {
compress: true,
}
Third-Party Scripts: next/script
Third-party scripts (analytics, chat widgets, consent platforms) are the most common source of poor INP (Interaction to Next Paint) and LCP scores.
import Script from "next/script"
// Strategy options:
// "afterInteractive" — loads after page is interactive (default; good for analytics)
// "lazyOnload" — loads during idle time (chat widgets, low-priority scripts)
// "beforeInteractive" — loads before hydration (consent managers that must run first)
// "worker" — loads in a web worker via Partytown (experimental)
// Google Analytics — load after interactive
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
// Chat widget — load during idle time
<Script
src="https://cdn.chatprovider.com/widget.js"
strategy="lazyOnload"
/>
Never use a plain <script> tag in layout.tsx for third-party scripts — they block rendering and tank LCP. Use next/script with an explicit loading strategy.
Bundle Analysis
Large JavaScript bundles slow Time to First Byte (TTFB) and increase parse time, hurting INP.
First, wire up the analyzer in next.config.js — without this the ANALYZE env var does nothing:
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
})
module.exports = withBundleAnalyzer({
// ...your existing config
})
Then install and run:
npm install --save-dev @next/bundle-analyzer
# Opens the bundle visualizer in your browser
ANALYZE=true npm run build
Look for:
- Large dependencies that could be replaced with smaller alternatives (moment.js → date-fns, lodash → individual imports)
- Packages loaded in server components that don't need to be (they add to the client bundle only if in client components)
- Duplicate dependencies (two versions of React, two HTTP libraries)
Common App Router SEO Pitfalls
Pitfall 1: Metadata in client components
metadata exports and generateMetadata functions only work in server components. If you mark a page as 'use client', the metadata export is silently ignored.
Fix: Keep page.tsx as a server component. Extract interactive elements into child client components.
Pitfall 2: Forgetting metadataBase
Without metadataBase, absolute URLs in Open Graph, Twitter Card, and canonical tags are wrong in non-production environments.
Fix: Set it in root layout.tsx using an environment variable.
Pitfall 3: Noindex on staging, forgotten at launch
Set noindex in staging via environment variable, not hardcoded:
robots: {
index: process.env.NEXT_PUBLIC_ROBOTS_INDEX !== "false",
follow: process.env.NEXT_PUBLIC_ROBOTS_INDEX !== "false",
}
Pitfall 4: Missing alt attributes on next/image
Alt is required on next/image. A blank alt="" is correct for decorative images (crawlers ignore them). Descriptive alt text on content images is both an accessibility requirement and an SEO signal.
Pitfall 5: Hydration errors breaking page rendering
Hydration errors cause React to re-render the entire component tree from scratch client-side, potentially showing a blank page briefly. Googlebot may see incomplete content.
Signs: "Text content did not match" warnings in browser console. Fix: Ensure server and client render identical initial HTML. Avoid Math.random(), Date.now(), or window in server components.
Pitfall 6: loading="lazy" on LCP images
next/image adds loading="lazy" by default. For the hero image (LCP element), this delays loading until the image enters the viewport — which makes it load later, not earlier.
Fix: Add priority to the LCP image. It removes lazy loading and adds a preload link.
Pitfall 7: JSON-LD with unescaped characters
If page content contains </script> in any user-provided data inserted into JSON-LD, it terminates the script tag early.
Fix: Use JSON.stringify() — it escapes <, >, and & by default. Never manually build JSON strings for structured data.
Pre-Launch SEO Checklist
Metadata
-
metadataBaseset in root layout -
title.templateconfigured so all pages have brand suffix - Every page has unique
titleanddescription -
alternates.canonicalon every page (absolute URL) - OG image resolves correctly (check
<meta property="og:image">in view source)
Crawlability
-
robots.tscreated, not accidentally blocking important routes -
sitemap.tsincludes all public pages, excludesnoindexpages - Sitemap submitted to Google Search Console
- No redirect chains (A → B → C — flatten to A → C)
-
wwwvsnon-wwwredirect configured at DNS level
Structured Data
- Organization + WebSite schema on homepage
- Article schema on blog posts (includes
datePublished,dateModified) - BreadcrumbList on all inner pages
- FAQPage JSON-LD only where visible Q&A exists (no Google FAQ rich result expected for commercial sites)
- All schemas validated via Rich Results Test
Images
- LCP image has
priorityprop - All content images have descriptive
alttext - Decorative images have
alt="" -
sizesprop set for responsive images - No hydration errors related to images
Performance
-
next/fontused for all custom fonts (no Google Fonts<link>tags) - Third-party scripts loaded with
next/scriptand appropriatestrategy - Bundle analysis run — no obvious oversized dependencies
- Core Web Vitals measured with Lighthouse or PageSpeed Insights
Rendering
- SEO-critical content in server components (not behind
useEffect/useState) - Dynamic routes have
generateStaticParamsfor SSG -
force-dynamicnot used on public SEO pages unless necessary
Headers
- Security headers configured in
next.config.js -
Cache-Control: immutableon/_next/static/ - HSTS header present
SEO on Next.js is mostly consistent metadata, validated structured data, images and fonts that do not fight the browser, and a sitemap that matches reality. We ship that as part of our technical SEO work. For a ready-made 1200×630 Open Graph file after a rebrand, use our free logo generator or the full platform guide. Want a review first? Send a brief.