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.

· Updated Jun 7, 2026· 21 min read· Varnox Team

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 Name or Brand Name | Page Name
  • Unique per page — duplicate titles tell crawlers the pages are the same
  • Use title.template in 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 noindex pages (confuses crawlers — they can't crawl a URL the sitemap suggests is important)
  • Stale lastModified dates (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.

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

  • metadataBase set in root layout
  • title.template configured so all pages have brand suffix
  • Every page has unique title and description
  • alternates.canonical on every page (absolute URL)
  • OG image resolves correctly (check <meta property="og:image"> in view source)

Crawlability

  • robots.ts created, not accidentally blocking important routes
  • sitemap.ts includes all public pages, excludes noindex pages
  • Sitemap submitted to Google Search Console
  • No redirect chains (A → B → C — flatten to A → C)
  • www vs non-www redirect 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 priority prop
  • All content images have descriptive alt text
  • Decorative images have alt=""
  • sizes prop set for responsive images
  • No hydration errors related to images

Performance

  • next/font used for all custom fonts (no Google Fonts <link> tags)
  • Third-party scripts loaded with next/script and appropriate strategy
  • 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 generateStaticParams for SSG
  • force-dynamic not used on public SEO pages unless necessary

Headers

  • Security headers configured in next.config.js
  • Cache-Control: immutable on /_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.

← Back to blog

Book your free consultation