Skip to content

Content Platform — SEO

Technical SEO implementation patterns for Next.js App Router content sites. SEO is not a feature — it is a structural requirement for every content platform.


Next.js Metadata API

Root Layout Metadata

SCOPE_ITEM: Set metadataBase in root layout — all relative OG URLs resolve from this SCOPE_ITEM: Default title with template for child pages SCOPE_ITEM: Default description for pages that don't override SCOPE_ITEM: Default Open Graph configuration

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://client-domain.eu'),
  title: {
    default: 'Client Name',
    template: '%s | Client Name',
  },
  description: 'Default site description for search engines',
  openGraph: {
    type: 'website',
    locale: 'nl_NL',
    siteName: 'Client Name',
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-video-preview': -1,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
};

Dynamic Page Metadata

SCOPE_ITEM: generateMetadata function on every content page SCOPE_ITEM: Title from content (50-60 characters, with fallback to seo_title) SCOPE_ITEM: Description from content excerpt or seo_description SCOPE_ITEM: Canonical URL set explicitly via alternates.canonical SCOPE_ITEM: Open Graph image from content cover image

// app/articles/[slug]/page.tsx
export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params;
  const article = await getArticleBySlug(slug);
  if (!article) return {};

  return {
    title: article.seoTitle || article.title,
    description: article.seoDescription || article.excerpt,
    alternates: {
      canonical: `/articles/${article.slug}`,
    },
    openGraph: {
      title: article.seoTitle || article.title,
      description: article.seoDescription || article.excerpt,
      type: 'article',
      publishedTime: article.publishedAt?.toISOString(),
      authors: [article.author.name],
      images: article.coverImage
        ? [{ url: article.coverImage.cdnUrl, width: 1200, height: 630 }]
        : [],
    },
    twitter: {
      card: 'summary_large_image',
      title: article.seoTitle || article.title,
      description: article.seoDescription || article.excerpt,
    },
  };
}

CHECK: metadataBase is set — without it, relative OG image URLs break in production CHECK: Every page has unique title and description — no duplicates CHECK: Canonical URLs are set explicitly — Next.js does NOT auto-generate them


Static Generation with generateStaticParams

Content Page Pre-rendering

SCOPE_ITEM: Use generateStaticParams to pre-render published content at build time SCOPE_ITEM: ISR with on-demand revalidation via webhook on content publish

// app/articles/[slug]/page.tsx
export async function generateStaticParams() {
  const articles = await db.query.articles.findMany({
    where: eq(articles.status, 'published'),
    columns: { slug: true },
  });
  return articles.map((a) => ({ slug: a.slug }));
}

// Enable ISR — revalidate on webhook trigger
export const revalidate = 3600; // fallback: revalidate hourly

Revalidation Strategy

SCOPE_ITEM: Webhook endpoint for CMS publish events SCOPE_ITEM: On publish, call revalidatePath('/articles/[slug]') SCOPE_ITEM: Also revalidate listing pages (homepage, category, tag pages) SCOPE_ITEM: Also revalidate sitemap on content publish/unpublish

// app/api/revalidate/route.ts
export async function POST(request: Request) {
  const { secret, slug, type } = await request.json();
  if (secret !== process.env.REVALIDATION_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  revalidatePath(`/articles/${slug}`);
  revalidatePath('/');
  revalidatePath('/sitemap.xml');

  if (type === 'category_change') {
    revalidatePath('/categories/[slug]', 'page');
  }

  return Response.json({ revalidated: true });
}

Sitemap Generation

Dynamic Sitemap

SCOPE_ITEM: app/sitemap.ts generates sitemap from published content SCOPE_ITEM: Include all published articles, pages, categories SCOPE_ITEM: Set lastModified from content updated_at SCOPE_ITEM: Set changeFrequency based on content type SCOPE_ITEM: Set priority (homepage 1.0, articles 0.8, categories 0.6)

// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const articles = await db.query.articles.findMany({
    where: eq(articles.status, 'published'),
    columns: { slug: true, updatedAt: true },
  });

  const categories = await db.query.categories.findMany({
    columns: { slug: true, updatedAt: true },
  });

  return [
    { url: 'https://client-domain.eu', lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
    ...articles.map((a) => ({
      url: `https://client-domain.eu/articles/${a.slug}`,
      lastModified: a.updatedAt,
      changeFrequency: 'weekly' as const,
      priority: 0.8,
    })),
    ...categories.map((c) => ({
      url: `https://client-domain.eu/categories/${c.slug}`,
      lastModified: c.updatedAt,
      changeFrequency: 'weekly' as const,
      priority: 0.6,
    })),
  ];
}

Large Site Sitemaps

IF: Site has > 50,000 URLs THEN: Use generateSitemaps to split into multiple sitemap files

// app/articles/sitemap.ts
export async function generateSitemaps() {
  const count = await db.select({ count: sql`count(*)` }).from(articles);
  const pages = Math.ceil(Number(count[0].count) / 50000);
  return Array.from({ length: pages }, (_, i) => ({ id: i }));
}

export default async function sitemap({ id }: { id: number }) {
  const offset = id * 50000;
  const batch = await db.query.articles.findMany({
    where: eq(articles.status, 'published'),
    limit: 50000,
    offset,
    columns: { slug: true, updatedAt: true },
  });
  return batch.map((a) => ({
    url: `https://client-domain.eu/articles/${a.slug}`,
    lastModified: a.updatedAt,
  }));
}

Robots.txt

SCOPE_ITEM: app/robots.ts with programmatic robots.txt generation

// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/admin/', '/api/', '/preview/'],
      },
    ],
    sitemap: 'https://client-domain.eu/sitemap.xml',
  };
}

Structured Data (JSON-LD)

Article Schema

SCOPE_ITEM: JSON-LD Article structured data on every article page SCOPE_ITEM: Include headline, author, datePublished, dateModified, image

// components/article-jsonld.tsx
export function ArticleJsonLd({ article }: { article: Article }) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: article.title,
    description: article.excerpt,
    author: {
      '@type': 'Person',
      name: article.author.name,
    },
    datePublished: article.publishedAt,
    dateModified: article.updatedAt,
    image: article.coverImage?.cdnUrl,
    publisher: {
      '@type': 'Organization',
      name: 'Client Name',
      logo: { '@type': 'ImageObject', url: 'https://client-domain.eu/logo.png' },
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://client-domain.eu/articles/${article.slug}`,
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  );
}

Additional Schema Types

OPTIONAL: BreadcrumbList — for breadcrumb navigation OPTIONAL: FAQPage — for FAQ-style content OPTIONAL: HowTo — for tutorial/guide content OPTIONAL: Organization — on about page OPTIONAL: WebSite with SearchAction — for sitelinks search box


Open Graph and Social Cards

Dynamic OG Images

OPTIONAL: Generate OG images dynamically using Next.js opengraph-image.tsx

// app/articles/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export default async function Image({ params }: { params: { slug: string } }) {
  const article = await getArticleBySlug(params.slug);
  return new ImageResponse(
    (
      <div style={{ display: 'flex', flexDirection: 'column', width: 1200, height: 630, background: '#fff', padding: 60 }}>
        <h1 style={{ fontSize: 56, fontWeight: 'bold', lineHeight: 1.2 }}>{article.title}</h1>
        <p style={{ fontSize: 28, color: '#666', marginTop: 'auto' }}>{article.author.name}</p>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

CHECK: OG image dimensions are 1200x630 (Facebook/LinkedIn standard) CHECK: Twitter card set to summary_large_image for visual content CHECK: Test OG previews with Facebook Sharing Debugger and Twitter Card Validator


Canonical URLs

SCOPE_ITEM: Every page has an explicit canonical URL SCOPE_ITEM: Canonical uses absolute URL with metadataBase SCOPE_ITEM: Paginated pages point canonical to first page (or self if unique content)

IF: Content is syndicated from external source THEN: Canonical points to original source URL

IF: Content exists at multiple URL paths (e.g., category + article) THEN: Canonical points to primary URL (article page)


Pagination SEO

IF: Listing pages have pagination THEN: Each page is self-canonicalized (page 2 canonical = page 2 URL)

SCOPE_ITEM: Clean pagination URLs (/articles?page=2, not /articles/page/2) SCOPE_ITEM: First page canonical omits page parameter OPTIONAL: Rel="next" and rel="prev" link headers OPTIONAL: "View all" page for content with < 100 items


Internationalized Content SEO

IF: Site has multi-language content THEN: Implement hreflang tags

OPTIONAL: Hreflang via alternates.languages in metadata OPTIONAL: Locale-prefixed URL structure (/nl/, /en/, /de/) OPTIONAL: x-default hreflang pointing to language selector or primary locale

export async function generateMetadata({ params }): Promise<Metadata> {
  return {
    alternates: {
      canonical: `/articles/${slug}`,
      languages: {
        'nl-NL': `/nl/articles/${slug}`,
        'en-US': `/en/articles/${slug}`,
        'x-default': `/articles/${slug}`,
      },
    },
  };
}

CHECK: Every language version has hreflang pointing to all other versions CHECK: Hreflang is bidirectional — if NL points to EN, EN must point back to NL CHECK: Content is genuinely translated, not auto-translated (Google penalizes thin translations)


SEO Monitoring

SCOPE_ITEM: Google Search Console integration SCOPE_ITEM: Core Web Vitals monitoring (LCP, CLS, INP) OPTIONAL: Automated broken link checker (weekly cron) OPTIONAL: Automated sitemap validation OPTIONAL: Keyword ranking tracking integration

CHECK: Search Console verified before launch CHECK: Sitemap submitted to Search Console CHECK: No noindex on pages that should be indexed CHECK: No orphan pages (every page reachable from navigation or internal links)