Back to Blog
SEO 12 min read

Next.js 15 SEO Best Practices: The Complete 2025 Guide

M
Mathew· February 15, 2025

A deep dive into optimizing your Next.js 15 applications for search engines — from metadata API to structured data, Core Web Vitals, and dynamic sitemap generation.

We've built dozens of Next.js apps at Teknoesis, and the questions we hear most from clients aren't about features — they're about visibility. "Why isn't my site showing up on Google?" is something we've answered more times than we can count. After working with Next.js 15 extensively, here's everything that actually matters for SEO in this version.

The Metadata API — Stop Using <Head>

Next.js 13 introduced the Metadata API, and if you're still using the old <Head> component from the pages router, you're missing out on built-in optimizations. In Next.js 15, the pattern looks like this:

// app/about/page.tsx
export const metadata: Metadata = {
  title: "About Us | Teknoesis",
  description: "We build websites and apps that drive real business growth.",
  alternates: {
    canonical: "https://yourdomain.com/about",
  },
  openGraph: {
    title: "About Us | Teknoesis",
    description: "...",
    url: "https://yourdomain.com/about",
    images: [{ url: "/images/og-about.jpg", width: 1200, height: 630 }],
  },
};

For dynamic pages — blog posts, product pages, portfolio items — use generateMetadata instead:

export async function generateMetadata({ params }) {
  const post = await getPost(params.slug);
  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `https://yourdomain.com/blog/${params.slug}` },
  };
}

One thing that trips people up: if a page has "use client" at the top, you cannot export metadata from it. You have to split the page into a server component wrapper (which exports metadata) and a client component for interactive parts. We ran into this exactly when building our own contact page — the form used useActionState, which required a client component, but we still needed metadata for SEO. The fix is a simple split: page.tsx stays as a server component and exports metadata, while ContactForm.tsx becomes the client-only interactive piece.

JSON-LD Structured Data

Google uses structured data to understand what your page is about and to generate rich results in search. JSON-LD is the format Google recommends, and in Next.js it goes directly inside your page component as a script tag:

export default function HomePage() {
  const schema = {
    "@context": "https://schema.org",
    "@type": "Organization",
    "@id": "https://yourdomain.com/#organization",
    name: "Your Company",
    url: "https://yourdomain.com",
    logo: {
      "@type": "ImageObject",
      url: "https://yourdomain.com/logo.png",
      width: 300,
      height: 80
    },
  };
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
      />
      {/* page content */}
    </>
  );
}

The most important schemas for most businesses are Organization (homepage), WebSite (enables the sitelinks search box in Google), BreadcrumbList (inner pages), and BlogPosting (blog articles). Don't overcomplicate it — start with these four and you'll cover 90% of what Google looks for.

One pattern worth following: use "@id" references to link schemas together rather than duplicating data. For example, instead of writing out the full organization object in every BlogPosting schema, reference it:

publisher: { "@id": "https://yourdomain.com/#organization" }

Google understands these references and builds a knowledge graph from them. It's cleaner and avoids inconsistencies if your organization details ever change.

Generating a Sitemap

Next.js 15 has a built-in sitemap route via app/sitemap.ts. No third-party plugin needed:

import type { MetadataRoute } from "next";
import { blogPosts } from "@/lib/data/blog";

export default function sitemap(): MetadataRoute.Sitemap {
  const staticPages = [
    { url: "https://yourdomain.com", lastModified: new Date("2025-01-01") },
    { url: "https://yourdomain.com/about", lastModified: new Date("2025-01-15") },
    { url: "https://yourdomain.com/services", lastModified: new Date("2025-01-20") },
  ];

  const blogPages = blogPosts.map((post) => ({
    url: `https://yourdomain.com/blog/${post.slug}`,
    lastModified: new Date(post.publishedAt),
    changeFrequency: "monthly" as const,
    priority: 0.7,
  }));

  return [...staticPages, ...blogPages];
}

A few things worth noting here: use hardcoded dates rather than new Date() for static pages. If your sitemap says every page was modified today, Google treats it as a signal that you might be gaming freshness. Only update the lastModified date when you actually meaningfully change the page content.

robots.txt — Keep It Simple

Place a robots.txt in your /public folder. The main thing to get right is blocking URL parameters that create duplicate content:

User-agent: *
Allow: /
Disallow: /blog?category=*
Disallow: /blog?page=*
Sitemap: https://yourdomain.com/sitemap.xml

Category filters, pagination parameters, and sort order parameters all create functionally duplicate pages from Google's perspective. They should not be indexed separately. Block them at the robots.txt level and ensure your canonical tags also handle this defensively.

Core Web Vitals in Next.js

Three metrics Google uses as ranking signals: LCP (Largest Contentful Paint), INP (Interaction to Next Paint — this replaced FID in March 2024), and CLS (Cumulative Layout Shift).

The single most common LCP mistake in Next.js: animating your hero headline with Framer Motion, starting from opacity: 0. Your headline is usually the LCP element, and if it starts invisible, the browser doesn't register it as painted during Google's measurement window. Use a plain <h1> for your above-the-fold headline — or at minimum, start any animation from opacity: 1 and only animate position.

For images, always use Next.js's Image component with explicit width and height props. This prevents layout shift and enables automatic format conversion to WebP or AVIF:

<Image
  src="/images/hero.jpg"
  alt="Our team working on a project"
  width={1200}
  height={600}
  priority
/>

The priority prop adds a rel="preload" link for the image and disables lazy loading. Use it on any image visible above the fold — never on images below the fold.

Security Headers

Google's search ranking systems do consider site security. Add security headers via next.config.ts:

const securityHeaders = [
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "SAMEORIGIN" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
];

export default {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

One Mistake Everyone Makes

Running Lighthouse audits in development mode. The scores are almost meaningless — Node.js is running in development, source maps are loaded, code isn't minified, and there's no caching. The gap between dev and production scores can be 30+ points on performance.

Always measure against a production build (next build && next start) or your actual deployed URL. If you're optimizing based on dev-mode Lighthouse numbers, you're solving the wrong problems.

Next.jsSEOTechnical SEOWeb Development
Let's Work Together

Ready to Work With a Software Development Agency That Delivers?

Get a free consultation and project estimate within 24 hours. No fluff — just an honest conversation about your goals, timeline, and budget.

Free consultation24-hour responseNo commitment required