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)