The blog is wired into the SEO system. Blog pages generate metadata from stored posts, use explicit canonical URLs, render JSON LD, appear in the sitemap, and feed RSS readers.
Canonical URLs
Blog URL helpers live in src/features/blog/utils/blog-post-seo.ts.
export function getBlogPostCanonicalUrl(slug: string) {
return `${siteOrigin}${WebRoutes.blogPost.path}/${slug}`
}Use stable, readable slugs. A published post URL should not change unless you are ready to handle redirects.
Post Metadata
The post page builds metadata from the stored post.
return {
title: post.title,
description: post.preview,
keywords: post.seoKeywords,
alternates: {
canonical,
},
openGraph: {
type: "article",
title: post.title,
description: post.preview,
url: canonical,
images: [{ url: imageUrl, width: 1200, height: 630, alt: post.title }],
},
}Use title, preview, seoKeywords, and imageSrc carefully. Those fields control the post headline, meta description, metadata keywords, social preview image, and RSS output.
Missing Posts
If a blog post cannot be found, the route returns noindex metadata.
return createNoIndexMetadata("Post not found", "The requested blog post could not be found.")This keeps missing or deleted content out of the index.
JSON LD
Blog posts render BlogPosting JSON LD through src/features/blog/components/blog-post-article-json-ld.tsx.
const schema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline,
description,
image: resolveAbsoluteUrl(imageSrc),
datePublished: datePublished.toISOString(),
dateModified: dateModified.toISOString(),
author: {
"@type": "Person",
name: authorName,
},
publisher: {
"@type": "Organization",
name: SiteConfig.name,
},
}The script is escaped before rendering, so structured data stays safe even when post fields come from the database.
Sitemap
The sitemap includes the blog index and every published blog post.
const blogRoutes = posts.map((post) => ({
url: getBlogPostCanonicalUrl(post.slug),
lastModified: post.updatedAt,
changeFrequency: "monthly",
priority: 0.7,
}))Sitemap generation lives in src/app/sitemap.ts.
RSS
The RSS feed lives at /rss.xml.
const posts = await listBlogPosts(RSS_FEED_LIMIT, 0)
const postUrl = WebRoutes.blogPost.withBaseUrl(post.slug)
const description = escapeXml(post.preview)
const categoryXml = post.seoKeywords.map((keyword) => `<category>${escapeXml(keyword)}</category>`).join("")The feed uses the post preview as the item description and SEO keywords as RSS categories. The route returns application/rss+xml and uses cache headers for public feed readers.
Cover Images
Blog cover images should be public and sized for social cards.
- Use a 1200 by 630 image when possible.
- Store public blog covers through the public Vercel Blob token.
- Keep the cover image relevant to the post.
- Avoid placeholder images for published content.
The same image can appear on the post page, blog cards, Open Graph previews, Twitter cards, and RSS readers.
Where To Customize
Use these files first:
src/features/blog/utils/blog-post-seo.tsfor canonical URLs and image URL resolution.src/app/(app)/(content)/blog/[slug]/page.tsxfor post metadata.src/features/blog/components/blog-post-article-json-ld.tsxfor article structured data.src/app/sitemap.tsfor sitemap inclusion.src/app/rss.xml/route.tsfor RSS feed output.