onezlabs

0%

SEO

SEO for Next.js: Technical Setup Checklist

The complete technical SEO setup for Next.js App Router — metadata API, dynamic sitemaps, robots.txt, structured data, and Core Web Vitals optimization.

May 16, 202613 min read
#Next.js#Technical SEO#App Router#Metadata#Structured Data#Core Web Vitals
SEO for Next.js: Technical Setup Checklist

Why Next.js Is the Best Framework for SEO

After building dozens of sites across WordPress, React SPAs, Gatsby, Astro, and Next.js, Next.js with the App Router is my consistent recommendation for any project where SEO performance matters. The reasons are concrete: server-side rendering is the default (no client-side-only content that crawlers miss), the Metadata API is purpose-built for search and social metadata, image optimization is baked in, and ISR (Incremental Static Regeneration) gives you static performance with dynamic content.

The framework handles many SEO fundamentals automatically, but the automatic handling only works if you configure it correctly. This checklist covers everything you need to set up — drawn directly from my full-stack development work on production Next.js sites.

Metadata API: Static and Dynamic

The App Router Metadata API replaces the manual <Head> manipulation from Pages Router and Next.js 12. Export a metadata object for static metadata or a generateMetadata function for dynamic metadata that depends on route parameters or fetched data.

// app/layout.tsx — Root layout with site-wide defaults
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://yourdomain.com"),
  title: {
    template: "%s | Your Site Name",
    default: "Your Site Name — Tagline",
  },
  description: "Your site description — 150-160 characters for meta description.",
  openGraph: {
    type: "website",
    locale: "en_US",
    url: "https://yourdomain.com",
    siteName: "Your Site Name",
  },
  twitter: {
    card: "summary_large_image",
    creator: "@yourhandle",
  },
  robots: {
    index: true,
    follow: true,
  },
  verification: {
    google: "your-google-verification-code",
  },
};
// app/blog/[slug]/page.tsx — Dynamic metadata for blog posts
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";

interface Props {
  params: Promise<{ slug: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    return { title: "Post Not Found" };
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      authors: [post.author.name],
      url: `https://yourdomain.com/blog/${slug}`,
      images: [
        {
          url: `/api/og?title=${encodeURIComponent(post.title)}`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    alternates: {
      canonical: `https://yourdomain.com/blog/${slug}`,
    },
  };
}

// Also export generateStaticParams for build-time generation
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

Dynamic Sitemap and robots.txt

Next.js generates sitemaps and robots.txt through special route files in the app directory. These are server-rendered and can include dynamic content from your database or CMS.

// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/blog";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();
  const BASE = "https://yourdomain.com";

  const staticRoutes: MetadataRoute.Sitemap = [
    { url: BASE, lastModified: new Date(), changeFrequency: "weekly", priority: 1.0 },
    { url: `${BASE}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 },
    { url: `${BASE}/services`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.9 },
    { url: `${BASE}/blog`, lastModified: new Date(), changeFrequency: "daily", priority: 0.8 },
    { url: `${BASE}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.6 },
  ];

  const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${BASE}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: "monthly",
    priority: 0.7,
  }));

  return [...staticRoutes, ...postRoutes];
}
// app/robots.ts
import type { MetadataRoute } from "next";

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

Structured Data (JSON-LD) in Next.js

Inject JSON-LD structured data using a <script> tag in your layout or page components. Server Components render this in the HTML before JavaScript runs, so it is immediately visible to crawlers.

// components/StructuredData.tsx
interface Props {
  data: Record<string, unknown>;
}

export function StructuredData({ data }: Props) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}

// Usage in a blog post page:
export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  const articleSchema = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    author: {
      "@type": "Person",
      name: post.author.name,
      url: "https://yourdomain.com/about",
    },
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    publisher: {
      "@type": "Organization",
      name: "Your Site Name",
      logo: {
        "@type": "ImageObject",
        url: "https://yourdomain.com/logo.png",
      },
    },
  };

  return (
    <>
      <StructuredData data={articleSchema} />
      {/* page content */}
    </>
  );
}

Image Optimization with next/image

The next/image component handles WebP/AVIF conversion, responsive sizing, lazy loading, and layout shift prevention automatically. Use it for every image on the site.

import Image from "next/image";

// Hero image — eager load, no lazy loading, high priority
<Image
  src="/hero.jpg"
  alt="Descriptive alt text with relevant keywords"
  width={1200}
  height={630}
  priority  // preloads the image — use only for LCP images
  quality={85}
  sizes="(max-width: 768px) 100vw, 50vw"
/>

// Below-the-fold image — lazy loaded by default
<Image
  src="/project-screenshot.jpg"
  alt="Screenshot of the project dashboard"
  width={800}
  height={450}
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

Warning: Only use priority on the LCP image (usually the hero image). Applying it to multiple images causes them to compete for bandwidth and can actually worsen LCP performance.

Core Web Vitals in Next.js

Next.js 15 ships with built-in performance optimizations, but you need to configure them correctly:

Partial Prerendering (PPR): Available in Next.js 15, PPR statically renders the page shell and streams dynamic content. This dramatically improves TTFB for pages with dynamic sections — the static shell loads instantly from CDN while dynamic content streams in.

Font optimization: Use next/font instead of loading Google Fonts via a link tag. next/font downloads fonts at build time, self-hosts them, and adds font-display: swap automatically.

// app/layout.tsx
import { Inter, Geist } from "next/font/google";

const geist = Geist({
  subsets: ["latin"],
  variable: "--font-geist",
  display: "swap",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={geist.variable}>
      <body>{children}</body>
    </html>
  );
}

Script optimization: Use next/script for third-party scripts with appropriate loading strategies. Use strategy="afterInteractive" for analytics (Google Analytics, PostHog) and strategy="lazyOnload" for chat widgets that do not need to be interactive immediately.

Open Graph Images with next/og

Dynamic OG images generated with the ImageResponse API give every page a unique, properly-sized social share image without manually creating PNGs for each page.

// app/api/og/route.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get("title") || "Default Title";

  return new ImageResponse(
    (
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          padding: "60px",
          width: "100%",
          height: "100%",
          background: "linear-gradient(135deg, #0d1117 0%, #1a2333 100%)",
          color: "white",
          fontFamily: "sans-serif",
        }}
      >
        <p style={{ fontSize: 24, color: "#22d3ee", marginBottom: 16 }}>
          yourdomain.com
        </p>
        <h1 style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.2, margin: 0 }}>
          {title}
        </h1>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

This setup covers the complete technical SEO foundation for a Next.js site. The combination of proper metadata, dynamic sitemaps, structured data, image optimization, and Core Web Vitals configuration gives you a strong technical baseline that directly supports rankings. For full-stack Next.js development with SEO built in from day one, see my full-stack services and project examples.

Frequently Asked Questions

Should I use Next.js App Router or Pages Router for SEO?

App Router (Next.js 13+) is the better choice for new projects. It has a purpose-built Metadata API that is more ergonomic than Pages Router's Head component, supports React Server Components for zero-bundle-size server rendering, and has better caching primitives. The SEO fundamentals are the same between routers, but App Router makes them cleaner to implement.

Does client-side navigation hurt SEO in Next.js?

No. Next.js handles this correctly. The initial page load is server-rendered HTML that Google can crawl and index. Subsequent navigation is client-side for performance, but Google does not need to follow those navigations — it crawls each URL independently. The key requirement is that each URL returns a complete HTML response from the server.

How do I handle SEO for dynamically generated pages in Next.js?

Use generateStaticParams to statically generate pages at build time where possible — these get the best performance and crawlability. For content that changes frequently, use generateMetadata with ISR (revalidate option in the route segment config). For truly dynamic pages (search results, user-specific content), ensure the server-rendered HTML includes all critical content even without JavaScript.

Share:TwitterLinkedIn

Get the newsletter

Practical articles on AI development, full stack engineering, and WordPress — delivered whenI publish, not on a schedule. No spam, ever.

JO

Johnbert Oñez

AI Solutions Engineer & Full Stack Developer

Johnbert builds AI systems, web applications, and WordPress solutions for clients worldwide. Based in Davao City, Philippines. 6+ years, 50+ projects.

More Like This

Enjoyed the article? Let's build something together.

From AI chatbots to SaaS products — I turn ideas into working software. Based in Davao City, available worldwide.