Skip to content
ExponaBLOG
TECHNICAL SEO FOR DEVELOPERS · DAY 052 / 100

Next.js SEO checklist: a practical guide for developers

Everything I verify before launching a Next.js site.

Expona Admin7 min read

Next.js SEO checklist: Everything I verify before launching

I've launched enough Next.js projects to know that SEO doesn't happen by accident. Whether you're shipping an e-commerce platform, a SaaS dashboard, or a content site, there's a specific sequence of checks I run before hitting production. This Next.js SEO checklist covers the five areas where most developers either nail it or leave search traffic on the table.

Metadata — Unique titles and descriptions, canonical URLs, Open Graph images

Metadata is your first impression with search engines and social platforms. Every page needs a unique title tag (50–60 characters), a descriptive meta description (150–160 characters), and a canonical URL to prevent duplicate content issues.

In Next.js 13+, I use the metadata API in layout.ts and page files:

html
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug);
  
  return {
    title: post.title,
    description: post.excerpt,
    canonical: `https://mysite.com/blog/${params.slug}`,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      url: `https://mysite.com/blog/${params.slug}`,
      images: [
        {
          url: post.ogImage,
          width: 1200,
          height: 630,
        },
      ],
    },
  };
}

For dynamic routes, I always compute canonical URLs based on the actual page path. Nothing tanks SEO faster than multiple canonical values or self-referential canonicals that point to the wrong URL.

Open Graph images should be 1200×630px and less than 500KB. I generate them at build time using a headless browser or a service like Vercel's OG Image Generation, not on every request.

Discovery — sitemap.ts and robots.ts generated from real data

Sitemaps and robots.txt are not optional; they're how search engines efficiently crawl your site. In Next.js 14+, I generate both dynamically from real data rather than hardcoding static lists.

Here's a sitemap that pulls actual blog posts and product pages:

html
// app/sitemap.ts
import { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
import { getAllProducts } from '@/lib/products';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://mysite.com';

  const posts = await getAllPosts();
  const products = await getAllProducts();

  const postEntries = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }));

  const productEntries = products.map((product) => ({
    url: `${baseUrl}/products/${product.id}`,
    lastModified: product.updatedAt,
    changeFrequency: 'daily' as const,
    priority: 0.9,
  }));

  return [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    ...postEntries,
    ...productEntries,
  ];
}

And robots.txt to allow or block crawlers strategically:

html
// app/robots.ts
import { MetadataRoute } from 'next';

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

I exclude admin routes, API endpoints, and internal Next.js folders. I also set a crawl delay if the site runs on constrained infrastructure.

Rendering — Indexable pages static or server-rendered. No client-only content

This is where I see the most mistakes. Client-side rendering kills SEO for content pages because crawlers get an empty shell before JavaScript runs. For every page you want indexed, make sure the meaningful content is in the HTML.

Use static generation for content that doesn't change often:

html
// app/blog/[slug]/page.tsx
export const revalidate = 86400; // ISR: revalidate every 24 hours

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

Use server rendering for frequently updated pages:

html
// app/products/[id]/page.tsx
export const dynamic = 'force-dynamic';

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  
  return (
    <section>
      <h1>{product.name}</h1>
      <p>Price: ${product.price}</p>
      <p>In stock: {product.stock}</p>
    </section>
  );
}

Never hide critical content behind a "Load More" button, tabs, or modals that require JavaScript to open. Crawlers won't interact with the page; they parse the initial HTML.

Performance — next/image with sizes, next/font, minimal client JavaScript

Core Web Vitals directly influence rankings. I use Next.js's built-in performance tools to keep scores high.

For images, always use next/image with explicit sizes to prevent layout shift:

html
// Use next/image with sizes attribute
import Image from 'next/image';

export default function ProductCard({ product }) {
  return (
    <div>
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={400}
        height={300}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        priority={false}
      />
      <h3>{product.name}</h3>
    </div>
  );
}

For fonts, load only the weights and subsets you use:

html
// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google';

const inter = Inter({ subsets: ['latin'], display: 'swap' });
const playfair = Playfair_Display({
  subsets: ['latin'],
  weight: ['700'],
  display: 'swap',
});

Minimize client-side JavaScript by keeping interactivity on the client side only when needed. Server components are the default in App Router; use them for data fetching and rendering static content.

Structured data — JSON-LD for articles, breadcrumbs and organization

Structured data helps search engines understand page content and enables rich snippets. I add JSON-LD in a <script> tag on pages that need it.

For a blog post:

html
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);

  const schema = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    image: post.ogImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: post.author.name,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
      />
      <article>
        <h1>{post.title}</h1>
        <p>{post.content}</p>
      </article>
    </>
  );
}

For an e-commerce product, include price and availability:

html
const productSchema = {
  '@context': 'https://schema.org/',
  '@type': 'Product',
  name: product.name,
  image: product.imageUrl,
  description: product.description,
  brand: {
    '@type': 'Brand',
    name: product.brand,
  },
  offers: {
    '@type': 'Offer',
    url: `https://mysite.com/products/${product.id}`,
    priceCurrency: 'USD',
    price: product.price.toString(),
    availability: product.stock > 0
      ? 'https://schema.org/InStock'
      : 'https://schema.org/OutOfStock',
  },
};

Add breadcrumb markup for category or nested pages:

html
const breadcrumbSchema = {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: [
    {
      '@type': 'ListItem',
      position: 1,
      name: 'Home',
      item: 'https://mysite.com',
    },
    {
      '@type': 'ListItem',
      position: 2,
      name: 'Products',
      item: 'https://mysite.com/products',
    },
    {
      '@type': 'ListItem',
      position: 3,
      name: product.name,
      item: `https://mysite.com/products/${product.id}`,
    },
  ],
};

Always validate your structured data with Google's Rich Results Test before going live.

Common mistakes

  • Duplicate metadata across pages: Copy-pasting the same title and description to multiple pages tanks CTR. Use dynamic generation.
  • Client-rendered content with no fallback HTML: If your nav, hero, or product list only renders on the client, crawlers see nothing.
  • Oversized images without next/image: Unoptimized images slow down LCP and CLS, which hurts rankings.
  • Canonical URLs pointing to the wrong domain or path: A mismatched canonical creates confusion; test it manually.
  • Forgetting to export metadata in dynamic routes: If generateMetadata isn't async and doesn't fetch fresh data, you get stale or generic tags.
  • Sitemap and robots.txt left out of app directory: Next.js won't auto-generate them; you must add routes for them to exist.
  • Structured data validation errors: Invalid JSON-LD doesn't break the page, but search engines ignore it. Always test in the Rich Results Test tool.

Key takeaways

  • Metadata must be unique, dynamic, and include canonical URLs and Open Graph tags to rank well and get good social previews.
  • Generate your sitemap and robots.txt from real data so they stay in sync with your content and don't require manual updates.
  • Render indexable pages server-side or statically; never hide critical content behind client-side interactivity.
  • Use next/image with sizes, next/font with specific weights, and minimal client JavaScript to hit Core Web Vitals targets.
  • Add JSON-LD structured data for your main content types (articles, products, organizations, breadcrumbs) and validate with Google's tools.
  • Test every change locally with npm run build && npm run start to catch rendering and metadata issues before production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles