SEO

Next.js SEO Best Practices for the App Router

How to set up SEO on the Next.js App Router: the Metadata API, structured data, sitemaps, robots rules and Core Web Vitals, updated for Next.js 16.

By TeknoesisPublished Updated 12 min read

We build on the Next.js App Router ourselves: this site and the BrokLeads web app both run on it. These are the SEO basics we set up every time, updated for Next.js 16.

The Metadata API — Stop Using <Head>

Next.js 13 introduced the Metadata API. If you're still using the old <Head> component from the pages router, you're missing out on built-in handling of titles, canonicals and Open Graph tags. On the App Router, 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. Split the page into a server component (which exports metadata) and a client component for the interactive parts. This site's contact page works exactly that way: the form uses useActionState, so it's a client component, while page.tsx stays a server component and exports the metadata.

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 useful types for most businesses are Organization (who you are), WebSite (helps Google show your site name), BreadcrumbList (inner pages) and BlogPosting (articles). Start with these four. Only mark up what's visible on the page, and skip review or rating markup about your own business: Google treats self-serving reviews as ineligible for rich results.

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 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];
}

Use real dates rather than new Date() for static pages. If your sitemap says every page changed today, the dates stop meaning anything and Google learns to ignore them. Only update lastModified when the content really changes, and list only canonical URLs that return 200.

robots.txt: Keep It Simple

Generate it with app/robots.ts so the sitemap URL always uses your canonical host:

import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/" }],
    sitemap: "https://www.yourdomain.com/sitemap.xml",
  };
}

Be careful with Disallow for filter or sort parameters. A blocked URL can't be crawled, so Google never sees its canonical tag. For most sites it's better to give the clean URL a self-referencing canonical and avoid linking to parameter URLs at all. Keep robots.txt blocks for parameters that create endless crawl paths.

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).

A common LCP mistake: fading the hero headline in from opacity: 0. The headline is often the LCP element, and LCP is recorded when it first renders visibly, so a delayed fade pushes LCP back by the length of the delay. Keep the above-the-fold headline visible from the first paint and animate something else.

For images, use Next.js's Image component with explicit width and height. That reserves space (no layout shift) and serves WebP or AVIF automatically:

<Image
  src="/images/hero.jpg"
  alt="The product dashboard"
  width={1200}
  height={600}
  preload
/>

In Next.js 16 the old priority prop is deprecated in favour of preload, and the docs suggest fetchPriority="high" or loading="eager" in most cases. Use one of them on the single image most likely to be your LCP element, and never on images below the fold.

Security Headers

HTTPS is a lightweight ranking signal. Security headers aren't, but they protect your visitors and cost nothing to add in 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 against the development server. The scores mean very little there: code isn't minified, extra development tooling runs in the page, and nothing is cached, so performance scores come out far lower than in production.

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.

We set these defaults up on every Next.js website we build. To see them on a live, statically generated site, read the Rice Purity Test case study.

Want help with your own project?

A few lines is enough. We'll ask the right questions on the call.

  1. We reply within one working day

    By email or WhatsApp, whichever you prefer.

  2. A short call

    To understand the goal, the people who'll use it and any deadline.

  3. A written scope and estimate

    Before any work starts, so you know what you're agreeing to.

So we can reply on WhatsApp if you prefer.

What are you building, who is it for, and is there a deadline?

We reply within one working day. By sending this you agree that Teknoesis (Lahore, Pakistan) may contact you about your request and store your details as described in our Privacy Policy. We use analytics tools; see Your Privacy Choices in the footer.