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.