Back to Blog

Complete Next.js SEO Guide: From Zero to Hero

Adeel Imran
Adeel Imran

SEO determines whether your Next.js application gets discovered or buried in search results. This guide walks you through production-ready SEO implementation from metadata foundations through performance optimization. Every code example comes directly from my blog, tested and proven to work in a real application.

TL;DR

If you're short on time, here's what you need to implement for solid Next.js SEO:

  • metadataBase in your root layout—without it, OG images become relative URLs that social platforms can't fetch
  • sitemap.ts and robots.ts files — auto-served at /sitemap.xml and /robots.txt
  • Unique titles per page using the title.template pattern
  • Canonical URLs on every page to prevent duplicate content issues
  • JSON-LD structured data for rich search results (Person and Article schemas)
  • next/image with alt text and priority for above-fold images
  • next/font to eliminate layout shift from font loading
  • GDPR-compliant analytics — load tracking only after consent

Bookmark the Testing Tools Reference for validation, and check Common Mistakes before you ship.

Prerequisites

This guide assumes a Next.js 15 project with the App Router already initialized. No additional npm packages are required beyond what comes with Next.js—the examples use only the built-in APIs.


Table of Contents

  1. Project Structure for SEO
  2. The Metadata Foundation
  3. Building Reusable Metadata Helpers
  4. Dynamic Sitemaps
  5. Robots.txt Configuration
  6. Web App Manifest for PWA Support
  7. Structured Data (JSON-LD)
  8. Page-Level SEO Implementation
  9. Blog Posts with Article Metadata
  10. Privacy-Compliant Analytics
  11. Social Media Integration
  12. Performance Considerations
  13. Testing Tools Reference
  14. Common SEO Mistakes

Project Structure for SEO

Next.js 15 with the App Router provides special metadata files that automatically generate SEO assets. Here's how to organize your project:

app/
├── layout.tsx          # Root layout with global metadata
├── page.tsx            # Homepage with structured data
├── sitemap.ts          # Dynamic sitemap generation
├── robots.ts           # Robots.txt configuration
├── manifest.ts         # PWA manifest
├── blog/
│   ├── page.tsx        # Blog index with page metadata
│   └── [slug]/
│       └── page.tsx    # Dynamic blog posts with article metadata
lib/
└── seo.ts              # Reusable metadata builders

The beauty of Next.js is that these special files automatically generate their respective assets at the correct URLs—no manual setup required.


The Metadata Foundation

Every Next.js app needs a solid metadata foundation in the root layout. This establishes defaults that all pages inherit, and the Next.js Metadata API provides a powerful, type-safe way to manage it.

Start by creating lib/seo.ts with the helper functions and constants you'll need:

// lib/seo.ts
import type { Metadata } from "next";

const SITE_URL = "https://www.adeelhere.com";
const DEFAULT_TITLE =
  "Adeel Imran | Expert SaaS MVP Developer & TypeScript Consultant";
const SITE_SHORT_TITLE = "Adeel Imran";
const DEFAULT_DESCRIPTION =
  "Launch and scale SaaS products faster with Adeel Imran, a senior TypeScript consultant and Next.js engineer specializing in high-impact MVPs.";
const DEFAULT_TWITTER = "@adeelibr";
const DEFAULT_OG_IMAGE = {
  url: "/opengraph-image.png",
  width: 1200,
  height: 630,
  alt: "Adeel Imran - Senior Next.js Consultant for High-Velocity SaaS MVPs",
};

// Helper to convert relative paths to absolute URLs
const absoluteUrl = (pathOrUrl?: string) => {
  if (!pathOrUrl) return SITE_URL;
  try {
    return new URL(pathOrUrl, SITE_URL).toString();
  } catch {
    return SITE_URL;
  }
};

// Normalize image with defaults
const normalizeImage = (image?: OgImageDescriptor) => {
  const img = image ?? DEFAULT_OG_IMAGE;
  return {
    url: absoluteUrl(img.url),
    width: img.width ?? DEFAULT_OG_IMAGE.width,
    height: img.height ?? DEFAULT_OG_IMAGE.height,
    alt: img.alt ?? DEFAULT_OG_IMAGE.alt,
  };
};

// Build canonical path
const buildCanonical = (path?: string) => {
  if (!path) return "/";
  return path.startsWith("/") ? path : `/${path}`;
};

export const rootMetadata: Metadata = {
  metadataBase: new URL(SITE_URL),
  title: {
    default: DEFAULT_TITLE,
    template: `%s | ${SITE_SHORT_TITLE}`,
  },
  description: DEFAULT_DESCRIPTION,
  authors: [{ name: "Adeel Imran", url: SITE_URL }],
  creator: "Adeel Imran",
  publisher: "Adeel Imran",
  keywords: [
    "Adeel Imran",
    "SaaS consultant",
    "Next.js expert",
    "TypeScript developer",
    "Full stack engineer",
    "hire react developer",
    "freelance frontend consultant",
  ],
  alternates: {
    canonical: "/",
  },
  openGraph: {
    type: "website",
    locale: "en_US",
    url: SITE_URL,
    siteName: SITE_SHORT_TITLE,
    title: DEFAULT_TITLE,
    description: DEFAULT_DESCRIPTION,
    images: [normalizeImage()],
  },
  twitter: {
    card: "summary_large_image",
    title: DEFAULT_TITLE,
    description: DEFAULT_DESCRIPTION,
    site: DEFAULT_TWITTER,
    creator: DEFAULT_TWITTER,
    images: [absoluteUrl(DEFAULT_OG_IMAGE.url)],
  },
  robots: {
    index: true,
    follow: true,
  },
  category: "technology",
};

Then in your root layout:

// app/layout.tsx
import { rootMetadata } from "@/lib/seo";

export const metadata = rootMetadata;

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>{children}</body>
    </html>
  );
}

What Each Field Does

  • metadataBase: Resolves relative URLs in OG images and canonical links. Without it, /og-image.png becomes a relative path that social platforms can't fetch (docs)
  • Title Template: The %s | Adeel Imran pattern means page titles automatically become "Page Name | Adeel Imran" (docs)
  • Canonical URLs: Tells search engines the preferred version of a page, preventing duplicate content penalties (Google's guide)
  • OpenGraph: The protocol that powers rich social media previews on Facebook, LinkedIn, and more (ogp.me)
  • Twitter Card: The summary_large_image card type displays rich previews on X/Twitter (docs)

✓ Verify Your Implementation

  1. Run npm run dev and open your site in the browser
  2. Right-click → "View Page Source" and search for <title> and <meta name="description"
  3. Confirm your OpenGraph tags appear: look for <meta property="og:title" and <meta property="og:image"
  4. Check that <html lang="en"> is present at the top of the document

Building Reusable Metadata Helpers

Rather than duplicating metadata logic across pages, you've already created helper functions in lib/seo.ts. Now add the buildPageMetadata function to that same file:

// lib/seo.ts (continued)
type OgImageDescriptor = {
  url: string;
  width?: number;
  height?: number;
  alt?: string;
};

type BuildMetadataOptions = {
  title: string;
  description?: string;
  path?: string;
  keywords?: string[];
  image?: OgImageDescriptor;
  noIndex?: boolean;
};

export const buildPageMetadata = ({
  title,
  description,
  path,
  keywords,
  image,
  noIndex,
}: BuildMetadataOptions): Metadata => {
  const metaDescription = description ?? DEFAULT_DESCRIPTION;
  const canonicalPath = buildCanonical(path);
  const ogImage = normalizeImage(image);
  const absoluteCanonical = absoluteUrl(canonicalPath);

  const metadata: Metadata = {
    title,
    description: metaDescription,
    keywords,
    alternates: {
      canonical: canonicalPath,
    },
    openGraph: {
      type: "website",
      siteName: SITE_SHORT_TITLE,
      title,
      description: metaDescription,
      url: absoluteCanonical,
      images: [ogImage],
    },
    twitter: {
      card: "summary_large_image",
      title,
      description: metaDescription,
      site: DEFAULT_TWITTER,
      creator: DEFAULT_TWITTER,
      images: [ogImage.url],
    },
  };

  if (typeof noIndex === "boolean") {
    metadata.robots = {
      index: !noIndex,
      follow: !noIndex,
    };
  }

  return metadata;
};

Usage in Pages

// app/blog/page.tsx
import { buildPageMetadata } from "@/lib/seo";

export const metadata = buildPageMetadata({
  title: "Blog",
  description:
    "Insights on web development, TypeScript, React, and building scalable SaaS applications.",
  path: "/blog",
  keywords: [
    "React development insights",
    "Next.js best practices",
    "TypeScript tips from expert",
    "SaaS development blog",
  ],
});

✓ Verify Your Implementation

  1. Navigate to a page using buildPageMetadata() (e.g., /blog)
  2. View page source and confirm the title follows your template pattern (e.g., "Blog | Adeel Imran")
  3. Verify the canonical URL matches the path you specified: look for <link rel="canonical" href="..."
  4. Run npm run build to catch any TypeScript errors in your metadata helpers

Dynamic Sitemaps

Without a sitemap, Google relies solely on crawling links, which can miss orphaned pages or delay indexing of new content by days or weeks. A sitemap is your direct line to search engines, telling them exactly what pages exist and when they were last updated.

Next.js makes sitemap generation elegant. The file is automatically served at /sitemap.xml. Create a sitemap.ts file:

// app/sitemap.ts
import { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/api"; // Your blog data source

const baseUrl = "https://www.adeelhere.com";

export default function sitemap(): MetadataRoute.Sitemap {
  const posts = getAllPosts();

  // Map blog posts to sitemap entries
  const blogPosts: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.date),
    changeFrequency: "monthly" as const,
    priority: 0.7,
  }));

  return [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: "monthly" as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: posts.length > 0 ? new Date(posts[0].date) : new Date(),
      changeFrequency: "weekly" as const,
      priority: 0.8,
    },
    {
      url: `${baseUrl}/experience`,
      changeFrequency: "monthly" as const,
      priority: 0.6,
    },
    ...blogPosts,
  ];
}

Priority Strategy

  • 1.0: Homepage (most important)
  • 0.8: Content hubs (blog index)
  • 0.7: Individual content pieces (blog posts)
  • 0.6: Supporting pages (about, experience)

The lastModified field signals to search engines when content was last updated, helping them prioritize crawling. Omit it for static pages.

✓ Verify Your Implementation

  1. Run npm run dev and visit http://localhost:3000/sitemap.xml in your browser
  2. Confirm all your pages appear with correct URLs and priorities
  3. Check that blog posts are listed with their publication dates as lastmod
  4. Validate your sitemap structure at XML Sitemap Validator

Robots.txt Configuration

Control how search engines crawl your site using the robots.txt file convention. For a deeper understanding of robots.txt directives, see Google's robots.txt documentation:

// app/robots.ts
import { MetadataRoute } from "next";

const SITE_URL = "https://www.adeelhere.com";

export default function robots(): MetadataRoute.Robots {
  return {
    host: SITE_URL,
    sitemap: `${SITE_URL}/sitemap.xml`,
    rules: [
      {
        userAgent: "*",
        allow: "/",
      },
    ],
  };
}

This generates:

User-agent: *
Allow: /
Host: https://www.adeelhere.com
Sitemap: https://www.adeelhere.com/sitemap.xml

When to Block Crawlers

If you have admin pages or preview routes:

rules: [
  {
    userAgent: "*",
    allow: "/",
    disallow: ["/admin/", "/api/", "/_preview/"],
  },
];

✓ Verify Your Implementation

  1. Visit http://localhost:3000/robots.txt in your browser
  2. Confirm it shows User-agent: * and Allow: /
  3. Verify the Sitemap: directive points to your sitemap URL
  4. If you added disallow rules, test that those paths return the expected behavior

Web App Manifest for PWA Support

A web app manifest enables PWA features and improves how your site appears when saved to home screens. Next.js provides a convenient manifest file convention for this:

Note: A manifest alone doesn't make your site a full PWA—you also need a service worker for offline support. However, it does enable "Add to Home Screen" functionality and controls your app's appearance when launched.

// app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    id: "adeelhere-com",
    name: "Adeel Imran | Expert SaaS MVP Developer & TypeScript Consultant",
    short_name: "Adeel Imran",
    description: "Launch and scale SaaS products faster with Adeel Imran.",
    start_url: "/",
    scope: "/",
    display: "standalone",
    orientation: "any",
    background_color: "#ffffff",
    theme_color: "#dc2626",
    categories: ["business", "productivity", "developer tools"],
    icons: [
      {
        src: "/favicon.ico",
        sizes: "48x48",
        type: "image/x-icon",
      },
      {
        src: "/icon-192.png",
        sizes: "192x192",
        type: "image/png",
      },
      {
        src: "/icon-512.png",
        sizes: "512x512",
        type: "image/png",
      },
      {
        src: "/icon-512.png",
        sizes: "512x512",
        type: "image/png",
        purpose: "maskable",
      },
    ],
  };
}

Icon Requirements

  • favicon.ico: Browser tab icon
  • 192x192: Android home screen
  • 512x512: Splash screens and higher resolution displays
  • maskable: Adaptive icon for Android that can be cropped to different shapes (circular, squircle, etc.) without cutting off important content—design these with extra padding around your logo

✓ Verify Your Implementation

  1. Visit http://localhost:3000/manifest.webmanifest in your browser
  2. Confirm all required fields are present: name, short_name, icons, start_url, display
  3. Open Chrome DevTools → Application tab → Manifest section to see a visual preview
  4. Run Lighthouse (DevTools → Lighthouse) and check the "PWA" category for manifest-related warnings
  5. Test your icons by visiting each icon URL directly (e.g., /icon-192.png)

Structured Data (JSON-LD)

Ever noticed those rich snippets in Google search results—FAQ dropdowns, star ratings, or product cards? That's structured data at work. Sites with rich results typically see 20-30% higher click-through rates than plain blue links.

Structured data helps search engines understand your content and can trigger these rich results. We use JSON-LD format with vocabulary from Schema.org.

Here's how to implement different schemas:

Person Schema (Homepage)

// app/page.tsx
const personJsonLd = {
  "@context": "https://schema.org",
  "@type": "Person",
  name: "Adeel Imran",
  url: "https://www.adeelhere.com",
  image: "https://www.adeelhere.com/profile/adeelimran.jpeg",
  sameAs: [
    "https://x.com/adeelibr",
    "https://www.linkedin.com/in/adeelimran/",
    "https://www.github.com/adeelibr/",
    "https://www.youtube.com/@adeelibr",
  ],
  jobTitle: "Senior Full Stack Developer & Consultant",
  worksFor: {
    "@type": "Organization",
    name: "Self-employed",
  },
  description:
    "Expert SaaS MVP Developer & TypeScript Consultant with 10+ years of experience.",
  knowsAbout: ["React", "Next.js", "TypeScript", "Node.js", "SaaS Development"],
};

export default function Home() {
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(personJsonLd).replace(/</g, "\\u003c"),
        }}
      />
      {/* Page content */}
    </>
  );
}

Article Schema (Blog Posts)

// app/blog/[slug]/page.tsx
function generateArticleJsonLd(post: {
  title: string;
  excerpt: string;
  date: string;
  slug: string;
  coverImage: string;
  author: { name: string; picture: string };
}) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    datePublished: post.date,
    dateModified: post.date,
    author: {
      "@type": "Person",
      name: post.author.name,
      url: "https://www.adeelhere.com",
      image: `https://www.adeelhere.com${post.author.picture}`,
    },
    publisher: {
      "@type": "Person",
      name: "Adeel Imran",
      url: "https://www.adeelhere.com",
      logo: {
        "@type": "ImageObject",
        url: "https://www.adeelhere.com/profile/adeelimran.jpeg",
      },
    },
    mainEntityOfPage: {
      "@type": "WebPage",
      "@id": `https://www.adeelhere.com/blog/${post.slug}`,
    },
  };
}

Security Note

Always escape < characters in JSON-LD to prevent XSS:

JSON.stringify(jsonLd).replace(/</g, "\\u003c");

✓ Verify Your Implementation

  1. View page source and search for application/ld+json to find your structured data
  2. Copy the JSON content and paste it into Google's Rich Results Test
  3. Fix any errors or warnings the tool reports
  4. For deployed sites, test with Schema Markup Validator for additional validation
  5. Check that < characters are properly escaped as \u003c in the rendered output

Page-Level SEO Implementation

Every page should have targeted metadata. Here's the pattern:

// app/experience/page.tsx
import { buildPageMetadata } from "@/lib/seo";

export const metadata = buildPageMetadata({
  title: "Work Experience",
  description:
    "Explore Adeel Imran's journey building scalable SaaS products—consultancy leadership, senior engineering roles, and decade-long React expertise.",
  path: "/experience",
  keywords: [
    "senior react developer portfolio",
    "experienced TypeScript engineer",
    "hire full stack consultant",
    "Next.js expert for hire",
    "10 years frontend experience",
  ],
});

Keyword Strategy

  • Include variations: "hire react developer", "freelance Next.js consultant"
  • Intent-based: "for hire", "consultation", "expert"
  • Long-tail: "senior react developer portfolio", "10 years frontend experience"

✓ Verify Your Implementation

  1. Visit each page and view source to confirm unique <title> and <meta name="description"> tags
  2. Use Ahrefs' Free SEO Toolbar or similar to quickly inspect meta tags
  3. Verify canonical URLs are absolute and correct for each page
  4. Check that keywords appear naturally in your page content (not just in meta tags)

Blog Posts with Article Metadata

When someone shares your blog post on LinkedIn or Twitter, the difference between a plain URL and a rich preview card with your title, image, and description is the difference between getting clicked and getting ignored. Article metadata also signals content freshness to Google. A ranking factor for timely topics.

For dynamic routes, use generateMetadata—a special async function that Next.js calls at build time to generate page-specific metadata:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { formatISO, parseISO } from "date-fns";
import { buildArticleMetadata } from "@/lib/seo";
import { getPostBySlug, getAllPosts } from "@/lib/api";

type Params = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata(props: Params): Promise<Metadata> {
  const params = await props.params;
  const post = getPostBySlug(params.slug);

  if (!post) return notFound();

  const publishedTime = formatISO(parseISO(post.date));

  return buildArticleMetadata({
    title: post.title,
    description: post.excerpt,
    path: `/blog/${post.slug}`,
    image: post.ogImage?.url
      ? { url: post.ogImage.url, alt: post.title }
      : undefined,
    publishedTime,
    modifiedTime: publishedTime,
    authors: [post.author.name],
  });
}

// Enable Static Site Generation for all posts
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

The buildArticleMetadata helper extends page metadata with article-specific OpenGraph properties. Add this to the same lib/seo.ts file where we defined buildPageMetadata, buildCanonical, and absoluteUrl earlier:

// lib/seo.ts (continued)
type BuildArticleMetadataOptions = BuildMetadataOptions & {
  publishedTime: string;
  modifiedTime?: string;
  authors?: string[];
  tags?: string[];
};

export const buildArticleMetadata = ({
  title,
  description,
  path,
  keywords,
  image,
  publishedTime,
  modifiedTime,
  authors,
  tags,
  noIndex,
}: BuildArticleMetadataOptions): Metadata => {
  const baseMetadata = buildPageMetadata({
    title,
    description,
    path,
    keywords,
    image,
    noIndex,
  });

  const canonicalPath = buildCanonical(path);
  const absoluteCanonical = absoluteUrl(canonicalPath);
  const normalizedAuthors = authors?.length ? authors : ["Adeel Imran"];

  return {
    ...baseMetadata,
    openGraph: {
      ...(baseMetadata.openGraph ?? {}),
      type: "article",
      publishedTime,
      modifiedTime: modifiedTime ?? publishedTime,
      authors: normalizedAuthors,
      tags,
    },
  };
};

✓ Verify Your Implementation

  1. Run npm run build — check that generateStaticParams generates all your blog slugs
  2. Visit a blog post and view source — confirm og:type is "article" (not "website")
  3. Verify article:published_time and article:author meta tags are present
  4. Test a blog post URL in Facebook Sharing Debugger to preview the social card
  5. For X/Twitter, simply compose a tweet with your URL to preview the card.

Privacy-Compliant Analytics

GDPR and privacy laws require explicit consent before tracking users. Beyond legal compliance, privacy-respecting sites build user trust and browsers increasingly block non-consented trackers anyway.

The key principle: only load analytics after users opt in.

// components/cookie-consent.tsx
"use client";

import { useEffect, useState } from "react";
import { GoogleAnalytics } from "@next/third-parties/google";

const GA_TRACKING_ID = "G-YOUR_KEY_HERE";

export default function CookieConsent() {
  const [userConsented, setUserConsented] = useState(false);

  useEffect(() => {
    // Check if user previously saved consent preference
    const saved = localStorage.getItem("analytics-consent");
    if (saved === "true") {
      setUserConsented(true);
    }
  }, []);

  const handleConsentChange = (consented: boolean) => {
    setUserConsented(consented);
    localStorage.setItem("analytics-consent", consented ? "true" : "false");

    if (!consented) {
      // Disable GA and clear existing cookies
      window[`ga-disable-${GA_TRACKING_ID}`] = true;
      document.cookie = "_ga=; Max-Age=0; path=/;";
      document.cookie = "_gid=; Max-Age=0; path=/;";
    }
  };

  return (
    <>
      {userConsented && <GoogleAnalytics gaId={GA_TRACKING_ID} />}
      {/* Cookie banner UI with accept/decline buttons → calls handleConsentChange() */}
    </>
  );
}

Key Privacy Features

  1. Conditional Loading: Analytics only loads after user explicitly opts in
  2. Persistent Consent: Store preference in localStorage so users don't see the banner repeatedly
  3. Easy Opt-Out: Users can clear analytics cookies if they change their mind
  4. Privacy Policy Link: Always visible in the consent banner, explaining what data you collect

Pro Tip: Configure Google Analytics with anonymizeIp: true in your GA settings to avoid storing full IP addresses.


Social Media Integration

Links with rich previews and compelling image, clear title, and description get 2-3x more engagement than plain URLs. OpenGraph tags (which you've already implemented via buildPageMetadata) give you complete control over how your content appears when shared.

The SEO Essential: Proper External Link Handling

Always use rel="noopener noreferrer" for external links:

import Link from "next/link";

<Link
  href={externalUrl}
  target="_blank"
  rel="noopener noreferrer"
  aria-label={`Visit my ${label} profile (opens in new tab)`}
>
  {label}
</Link>;

Why this matters:

  • noopener: Prevents the opened page from accessing your window.opener property (security)
  • noreferrer: Hides referrer information from the destination (privacy + SEO signal control)
  • aria-label: Makes it clear to screen readers that the link opens in a new tab

✓ Verify Your Implementation

  1. Test your URLs in Facebook Sharing Debugger — click "Scrape Again" to clear cache
  2. For X/Twitter previews, compose a new tweet with your URL
  3. Test on LinkedIn by pasting your URL in a new post draft (LinkedIn caches aggressively—use LinkedIn Post Inspector to refresh)
  4. Inspect external links in DevTools to confirm rel="noopener noreferrer" is present
  5. Check that all external links have target="_blank" and appropriate aria-label for accessibility

Performance Considerations

SEO isn't just about metadata—performance directly impacts rankings. Google uses Core Web Vitals as ranking signals, measuring real-world user experience.

Understanding Core Web Vitals

Core Web Vitals are three metrics that Google considers essential for user experience:

Metric What It Measures Good Score Next.js Solution
LCP (Largest Contentful Paint) Loading performance—when the largest element becomes visible ≤ 2.5s next/image with priority, static generation
INP (Interaction to Next Paint) Responsiveness—delay between user interaction and visual feedback ≤ 200ms Server Components, minimal client JS
CLS (Cumulative Layout Shift) Visual stability—unexpected layout movements ≤ 0.1 next/font, next/image with dimensions

Why This Matters: Sites failing Core Web Vitals can see ranking penalties. Google's page experience update makes these metrics a tie-breaker between otherwise equal pages.

How Next.js Helps with Each Metric

LCP Optimization:

  • Use priority prop on above-the-fold images
  • Static generation pre-renders pages for instant delivery
  • Server Components reduce JavaScript payload

INP Optimization:

  • Server Components run on the server, reducing client-side JavaScript
  • Streaming with Suspense allows progressive rendering
  • Avoid heavy client-side computations during interactions

CLS Optimization:

  • next/image reserves space with width and height props
  • next/font eliminates flash of unstyled text (FOUT)
  • Avoid injecting content above existing content

Image Optimization

The next/image component automatically optimizes images for performance. Configure Next.js for external images:

// next.config.mjs
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.unsplash.com",
      },
    ],
  },
};

SEO-Critical Image Props:

import Image from "next/image";

// Hero image (above the fold) — use priority for LCP
<Image
  src="/hero-banner.jpg"
  alt="Descriptive text for screen readers and SEO"
  width={1200}
  height={630}
  priority // Preloads image, critical for LCP
/>

// Below-the-fold images — lazy load by default
<Image
  src={post.coverImage}
  alt={`Cover image for ${post.title}`}
  width={800}
  height={400}
  // No priority = lazy loaded automatically
/>
Prop SEO Impact When to Use
alt Critical — Screen readers, image search ranking, fallback text Always required for meaningful images
priority Improves LCP score Above-the-fold images (hero, header)
width + height Prevents CLS Always provide to reserve space
sizes Optimizes bandwidth Responsive images in grid layouts

Accessibility Note: Decorative images (purely visual, no informational content) should use alt="" to be skipped by screen readers. Never omit the alt attribute entirely.

Font Optimization

Use next/font for zero layout shift (eliminates CLS issues from font loading):

import { Geist as Font } from "next/font/google";

const font = Font({
  variable: "--font-sans",
  subsets: ["latin"],
  display: "swap", // Prevents invisible text during loading
});

Static Generation

Use generateStaticParams to pre-render all blog posts at build time—this is crucial for SEO as it ensures pages are fully rendered when crawlers visit:

export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

✓ Verify Your Implementation

  1. Run Lighthouse in Chrome DevTools → check both SEO and Performance scores
  2. Aim for 90+ on Performance; check for LCP, CLS, and FID/INP issues
  3. Use PageSpeed Insights for real-world performance data
  4. Verify images are served in modern formats (WebP/AVIF) via DevTools Network tab
  5. Check that fonts load with font-display: swap (no invisible text during loading)

Testing Tools Reference

Bookmark these tools for ongoing SEO validation:

Tool Purpose URL
Google Rich Results Test Validate JSON-LD structured data search.google.com/test/rich-results
Schema Markup Validator Additional structured data validation validator.schema.org
Facebook Sharing Debugger Preview & debug OpenGraph cards developers.facebook.com/tools/debug
OpenGraph.xyz Preview cards for Twitter, Facebook, LinkedIn opengraph.xyz
LinkedIn Post Inspector Clear LinkedIn's URL cache linkedin.com/post-inspector
Google Search Console Monitor indexing, submit sitemaps search.google.com/search-console
PageSpeed Insights Real-world performance & Core Web Vitals pagespeed.web.dev
Lighthouse Comprehensive audit (SEO, Performance, A11y) Built into Chrome DevTools
XML Sitemap Validator Validate sitemap structure xml-sitemaps.com/validate-xml-sitemap

Pro Tip: Social platforms cache aggressively. After updating OG tags, always use the debugger tools to "scrape again" or "fetch new data" before sharing links.


Common SEO Mistakes in Next.js

These are the issues I see most frequently when auditing Next.js applications:

1. Missing or Incorrect metadataBase

// ❌ Wrong: OG images with relative paths break
export const metadata: Metadata = {
  openGraph: {
    images: ["/og-image.png"], // Becomes invalid URL
  },
};

// ✅ Correct: Set metadataBase in root layout
export const metadata: Metadata = {
  metadataBase: new URL("https://www.yoursite.com"),
  openGraph: {
    images: ["/og-image.png"], // Now resolves to absolute URL
  },
};

Symptom: Social cards show broken images or no preview at all.

2. Duplicate Titles Across Pages

// ❌ Wrong: Same title on every page
export const metadata = {
  title: "My Company", // Identical everywhere
};

// ✅ Correct: Use template + unique page titles
// Root layout:
title: {
  default: "My Company",
  template: "%s | My Company",
}

// Page:
export const metadata = {
  title: "About Us", // Becomes "About Us | My Company"
};

Symptom: Google shows "Duplicate title tags" in Search Console.

3. Missing Alt Text on Images

// ❌ Wrong: No alt attribute
<Image src={photo} width={800} height={600} />

// ❌ Wrong: Useless alt text
<Image src={photo} alt="image" />
<Image src={photo} alt="photo.jpg" />

// ✅ Correct: Descriptive alt text
<Image src={photo} alt="Team collaborating around a whiteboard during sprint planning" />

// ✅ Correct: Decorative images use empty alt
<Image src={decorativeBorder} alt="" />

Symptom: Lighthouse SEO score drops, accessibility issues, missed image search traffic.

4. Forgetting Canonical URLs

// ❌ Wrong: No canonical, Google indexes query params as duplicates
// yoursite.com/blog/post
// yoursite.com/blog/post?utm_source=twitter  ← Indexed as separate page

// ✅ Correct: Explicit canonical
export const metadata = {
  alternates: {
    canonical: "/blog/post",
  },
};

Symptom: Diluted rankings from duplicate content.

5. Client-Side Only Content

// ❌ Wrong: Content loaded after JavaScript runs
export default function Page() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch("/api/content")
      .then((r) => r.json())
      .then(setData);
  }, []);
  return <div>{data?.content}</div>; // Empty when crawled
}

// ✅ Correct: Server-side data fetching
export default async function Page() {
  const data = await getContent(); // Fetched at build/request time
  return <div>{data.content}</div>; // Content visible to crawlers
}

Symptom: Google indexes empty or partial pages.

6. Blocking JavaScript in Robots.txt

# ❌ Wrong: Blocks Next.js assets
User-agent: *
Disallow: /_next/

# ✅ Correct: Allow all static assets
User-agent: *
Allow: /

You might think "CSS and JavaScript files aren't content—why would blocking them matter?" Here's the key insight: robots.txt controls crawling (fetching files), not indexing (appearing in search results).

When Googlebot visits your page, it sees HTML that references JavaScript files in /_next/. To actually render your React components and see the content, Googlebot must fetch and execute that JavaScript. If you block /_next/, Googlebot can't render your pages—it sees empty or broken content instead.

From Google's JavaScript SEO documentation: "Don't block Googlebot from your JavaScript or CSS files... Blocking these files means Google's algorithms can't render and index your content."

Symptom: Googlebot can't render your pages properly—indexes empty or partial content.

7. Missing Structured Data Escaping

// ❌ Wrong: XSS vulnerability if content contains <script>
dangerouslySetInnerHTML={{
  __html: JSON.stringify(jsonLd),
}}

// ✅ Correct: Escape < characters
dangerouslySetInnerHTML={{
  __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}

Symptom: Security vulnerability, potential script injection.


Checklist: SEO Audit for Your Next.js App

Before launching, verify:

  • Root metadata with metadataBase set
  • Title template for consistent branding
  • OpenGraph and Twitter cards on all pages
  • Canonical URLs on every page
  • Dynamic sitemap including all routes
  • Robots.txt allowing crawlers
  • Web app manifest with icons
  • Structured data (Person, Article, FAQ, etc.)
  • Privacy policy page
  • GDPR-compliant cookie consent
  • Proper rel attributes on external links
  • Alt text on all images
  • Semantic HTML (<main>, <article>, <nav>)
  • lang attribute on <html>

Conclusion

SEO in Next.js 15 isn't about complex tricks it's about systematically using the framework's built-in features to signal relevance to search engines.

The core strategy:

  1. Centralize metadata so every page has unique, descriptive titles and descriptions
  2. Use Next.js metadata files (sitemap.ts, robots.ts, manifest.ts) for automatic SEO asset generation
  3. Add structured data to enable rich search results (Person schema for profiles, Article schema for blog posts)
  4. Prioritize performance with static generation, optimized images, and system fonts
  5. Respect privacy by loading analytics only after user consent

These patterns come from a production site that ranks well for competitive keywords. Implement them systematically, and you'll have a solid SEO foundation that compounds over time as your content grows.


Further Reading

Official Next.js Documentation:

Web Standards & Google: