Skip to content
Next.js Tutorial 6 min read

Next.js SEO Setup — The Complete Technical Guide

Roomi Kh

Published April 16, 2026 · Reviewed April 16, 2026

Next.js SEO Setup — The Complete Technical Guide

Most Next.js projects ship with broken or missing SEO. Not because developers don't care — because the App Router changed how all of it works and the official docs spread the answers across a dozen pages. This guide consolidates everything I set up on every production site into one place.

Use this when you're starting a new App Router project or auditing an existing one.

Metadata API — Title Templates, Descriptions, and Keywords

The App Router replaces next/head with a metadata export. Set a site-wide default in your root layout.tsx, then override per route.

TSX
// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://yourdomain.com'),
  title: {
    default: 'Your Site Name',
    template: '%s | Your Site Name',
  },
  description: 'Your default site description.',
  keywords: ['keyword one', 'keyword two'],
  authors: [{ name: 'Your Name' }],
  creator: 'Your Company',
};

Override at the page level with a plain string or another template:

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

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
    keywords: post.keywords,
  };
}

The template in the root layout means every page title automatically becomes Page Title | Your Site Name without any extra work.

Dynamic Sitemap

Create app/sitemap.ts — Next.js serves it at /sitemap.xml automatically.

TS
// app/sitemap.ts
import type { MetadataRoute } from 'next';

const BASE_URL = 'https://yourdomain.com';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts(); // your data-fetching function

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

  const dynamicRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${BASE_URL}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly',
    priority: 0.7,
  }));

  return [...staticRoutes, ...dynamicRoutes];
}

Don't hardcode lastModified dates for dynamic content. Pull the real updatedAt from your CMS or database — Google uses it to decide crawl frequency.

Robots.ts — Production vs. Preview Environments

Never let preview deployments get indexed. app/robots.ts lets you branch on environment:

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

const BASE_URL = 'https://yourdomain.com';
const isProduction = process.env.VERCEL_ENV === 'production';

export default function robots(): MetadataRoute.Robots {
  if (!isProduction) {
    return {
      rules: { userAgent: '*', disallow: '/' },
    };
  }

  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] },
    ],
    sitemap: `${BASE_URL}/sitemap.xml`,
  };
}

On Vercel, VERCEL_ENV is production only on your production deployment. Preview and development deployments get a blanket disallow, so staging URLs never leak into search results.

Structured Data / JSON-LD

Structured data is the part most teams skip. Add it as a <script> tag in your layout or page components.

Organization schema in the root layout:

TSX
// app/layout.tsx
const organizationSchema = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  name: 'Your Company',
  url: 'https://yourdomain.com',
  logo: 'https://yourdomain.com/logo.png',
  contactPoint: {
    '@type': 'ContactPoint',
    contactType: 'customer service',
    email: 'hello@yourdomain.com',
  },
};

// Inside your layout JSX:
// <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />

Article + BreadcrumbList on blog posts:

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

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

  const breadcrumbSchema = {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: [
      { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://yourdomain.com' },
      { '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://yourdomain.com/blog' },
      { '@type': 'ListItem', position: 3, name: post.title, item: `https://yourdomain.com/blog/${post.slug}` },
    ],
  };

  return (
    <>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} />
      {/* page content */}
    </>
  );
}

Validate everything at schema.org/validator before shipping.

Dynamic Open Graph Images with ImageResponse

Static OG images work, but dynamic ones let every blog post and page have its own branded preview when shared on Slack or Twitter.

TS
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/posts';

export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function OGImage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'flex-end',
          width: '100%',
          height: '100%',
          backgroundColor: '#0f172a',
          padding: '60px',
        }}
      >
        <p style={{ color: '#94a3b8', fontSize: 24, margin: 0 }}>Your Blog</p>
        <h1 style={{ color: '#f8fafc', fontSize: 56, margin: '16px 0 0', lineHeight: 1.1 }}>
          {post.title}
        </h1>
        <p style={{ color: '#64748b', fontSize: 24, margin: '24px 0 0' }}>{post.author}</p>
      </div>
    ),
    { ...size }
  );
}

Next.js co-locates opengraph-image.tsx next to page.tsx and wires the meta tags automatically — you don't add anything to generateMetadata.

Canonical URLs and Alternates

Canonical URLs prevent duplicate content penalties when the same page is reachable at multiple URLs (trailing slash, query params, etc.).

TS
// app/blog/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);

  return {
    title: post.title,
    alternates: {
      canonical: `https://yourdomain.com/blog/${params.slug}`,
      languages: {
        'en-CA': `https://yourdomain.com/blog/${params.slug}`,
      },
    },
  };
}

Set alternates.canonical on every page — home, service pages, and blog posts. For the root layout, set it in the static metadata export pointing to the bare domain. Skip it and Google will guess, and it will guess wrong on roughly 20% of sites.

Verifying with Google Search Console

Once deployed, connect Search Console and run through this checklist before calling SEO done:

  1. Submit your sitemap at Search Console > Sitemaps > Add sitemap, entering /sitemap.xml.
  2. Use URL Inspection on your homepage, a blog post, and a service page. All three should return "URL is on Google" or pass the coverage check.
  3. Use Enhancements > Breadcrumbs and Rich Results to confirm structured data was parsed correctly.
  4. Check Coverage > Excluded for any pages blocked by robots or noindex that shouldn't be.
  5. Confirm the OG image appears in the URL Inspection panel under the Social tab.

Run a crawl with Screaming Frog or Ahrefs Site Audit after the first week of indexing to catch anything Search Console doesn't surface.

When to Bring in an Agency

This setup covers the technical layer — tags, schemas, and crawlability. It won't move rankings on its own. What actually drives organic growth is the combination of technical correctness, topical authority (consistent content targeting the right intent), and earning links from relevant sites.

If your team is shipping features and doesn't have bandwidth to run content strategy, keyword research, internal linking, and monthly audits on top of the development work, that's the right moment to bring in specialists.

ValeoFX handles the full stack — technical SEO audits, content planning, and implementation — for Toronto businesses that want search to be a reliable growth channel, not a guessing game. See how we approach SEO.

Keep the Thread Going

Continue Reading

Keep moving from insight to action

Use the next article, service, or case study to keep building the thread instead of bouncing back to the index.