Next.js 15 App Router: SEO & Performance Guide
Next.js 15 App Router has a new Metadata API, React Server Components, and static export changes. Here's how to get maximum SEO and performance in 2026.
Next.js 15 App Router changed how SEO metadata works, how static exports function, and how Server Components affect rendering. This is the complete guide to getting it right — based on running blog.shihub.online, letx.app, and quantumsketch.app on Next.js 15 with static export to Cloudflare Pages.
The Metadata API (App Router)
App Router uses metadata exports and generateMetadata functions — not <Head> from Pages Router. Critical: if you're migrating from Pages Router, remove all next/head usage.
Static metadata (known at build time):
// app/page.tsx
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Shihab Shahriar Antor — Architect's Logbook",
description: "Engineering notes, system design deep-dives, and product case studies by Shihab Shahriar Antor.",
// OpenGraph
openGraph: {
type: "website",
locale: "en_US",
url: "https://blog.shihub.online",
siteName: "The Logbook",
images: [{ url: "/og-image.png", width: 1200, height: 630 }],
},
// Twitter Card
twitter: {
card: "summary_large_image",
creator: "@_shihabShahriar",
images: ["/og-image.png"],
},
// Canonical URL
alternates: { canonical: "https://blog.shihub.online" },
// Crawling
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true },
},
}
Dynamic metadata (per post page):
// app/[slug]/page.tsx
import type { Metadata } from "next"
import { getPostBySlug } from "@/lib/posts"
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
return {
title: post.title, // template: "%s — Shihab Shahriar Antor"
description: post.description,
openGraph: {
type: "article",
publishedTime: post.date,
authors: ["Shihab Shahriar Antor"],
tags: post.tags,
},
alternates: { canonical: `https://blog.shihub.online/${slug}/` },
}
}
The title.template in layout.tsx automatically wraps page titles: "%s — Shihab Shahriar Antor" → each post gets "Post Title — Shihab Shahriar Antor".
Static export for Cloudflare Pages
Blog.shihub.online uses output: "export" for fully static HTML generation:
// next.config.ts
import type { NextConfig } from "next"
const config: NextConfig = {
output: "export",
trailingSlash: true, // /post-slug/ not /post-slug
images: { unoptimized: true } // required for static export
}
export default config
trailingSlash: true is critical for Cloudflare Pages — without it, /post-slug returns 404 (CloudFlare serves /post-slug/index.html but only if the trailing slash matches).
Static params generation:
// app/[slug]/page.tsx
export async function generateStaticParams() {
const posts = getAllPosts()
return posts.map(post => ({ slug: post.slug }))
}
export const dynamicParams = false // 404 for unknown slugs
Sitemap and robots via Route Handlers
// app/sitemap.ts
import { MetadataRoute } from "next"
import { getAllPosts } from "@/lib/posts"
export const dynamic = "force-static"
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllPosts()
const baseUrl = "https://blog.shihub.online"
return [
{ url: baseUrl, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
...posts.map(post => ({
url: `${baseUrl}/${post.slug}/`,
lastModified: new Date(post.date),
changeFrequency: "monthly" as const,
priority: 0.8,
})),
]
}
// app/robots.ts
import { MetadataRoute } from "next"
export const dynamic = "force-static"
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/" },
// Explicitly allow all known AI crawlers
{ userAgent: "GPTBot", allow: "/" },
{ userAgent: "ClaudeBot", allow: "/" },
{ userAgent: "Googlebot", allow: "/" },
{ userAgent: "Bingbot", allow: "/" },
],
sitemap: "https://blog.shihub.online/sitemap.xml",
}
}
force-static on sitemap and robots is required for static export — without it, Next.js 15 tries to render them dynamically and fails during next build.
Article JSON-LD per post
// app/[slug]/page.tsx
export default async function PostPage({ params }) {
const { slug } = await params
const post = getPostBySlug(slug)
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"description": post.description,
"datePublished": post.date,
"dateModified": post.date,
"author": {
"@type": "Person",
"name": "Shihab Shahriar Antor",
"url": "https://shihub.online",
"sameAs": ["https://github.com/shihabshahrier", "https://shahriarlabs.com"]
},
"publisher": {
"@type": "Organization",
"name": "Shahriar Labs",
"url": "https://shahriarlabs.com"
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": `https://blog.shihub.online/${slug}/`
}
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* post content */}
</>
)
}
Performance: React Server Components
App Router's RSC reduces JavaScript sent to the browser. Blog components that don't need interactivity run only on the server:
// Server Component (default in App Router) — no JS shipped to client
export default async function PostPage({ params }) {
const post = getPostBySlug(params.slug) // runs at build time
return <article>{/* rendered to static HTML */}</article>
}
// Client Component — only when you need interactivity
"use client"
export function SearchBar() {
const [query, setQuery] = useState("")
// ...
}
For a static blog, nearly everything is a Server Component. The only Client Components: interactive search, theme toggle, or copy-to-clipboard buttons.
FAQ
What's the difference between Next.js App Router and Pages Router metadata?
Pages Router uses <Head> from next/head for metadata. App Router uses export const metadata or export async function generateMetadata() — a typed API that generates correct <meta> tags automatically. They're incompatible; don't mix them.
Do I need force-static on sitemap and robots in Next.js 15?
Yes, if using output: "export". Without force-static, Next.js 15 tries to render these routes dynamically at request time, which fails in static export mode. Add export const dynamic = "force-static" to both files.
How do I add per-post Open Graph images in Next.js 15?
Use opengraph-image.tsx in the app/[slug]/ directory with ImageResponse from next/og. This generates per-post OG images at build time in static export mode.
What is trailingSlash: true and do I need it?
With output: "export", Next.js generates /slug/index.html for each route. Cloudflare Pages serves this file only when the URL has a trailing slash. Set trailingSlash: true in next.config.ts to ensure URLs match the generated file structure.
Written by Shihab Shahriar Antor — AI Engineer & Founder of Shahriar Labs. See also: SEO, GEO, and AEO for AI-Native Products in 2026 · Deploy Next.js to Cloudflare Pages: Full Guide.