_SH Log's
Back to Root
EST: 5 min read

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.

#nextjs#seo#typescript#performance

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.