feat(blog): enhance blog functionality and SEO

- Added a new '/blog' route to middleware for improved navigation.
- Updated sitemap to dynamically include blog posts and series URLs.
- Enhanced blog page metadata with Open Graph and Twitter card information.
- Implemented structured data for blog posts and series to improve SEO.
- Removed outdated blog post "Week 1: De Kickoff" from content.
- Introduced optional OG image field in blog frontmatter for better sharing visuals.
This commit is contained in:
colinislit
2026-01-01 10:56:34 +01:00
parent e7294e712f
commit 7df92dd2fb
22 changed files with 1625 additions and 19 deletions

View File

@@ -10,6 +10,21 @@ import { getAllPosts, getSeriesWithCounts, type BlogSeries } from '@/lib/mdx/blo
export const metadata = {
title: 'Blog - AI Speedrun',
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
openGraph: {
title: 'Blog - AI Speedrun',
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
type: 'website',
siteName: 'AI Speedrun',
locale: 'nl_NL',
},
twitter: {
card: 'summary',
title: 'Blog - AI Speedrun',
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
},
alternates: {
canonical: `${process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'}/blog`,
},
}
export default async function BlogPage() {

View File

@@ -31,14 +31,43 @@ export async function generateMetadata({ params }: PostPageProps) {
return { title: 'Post niet gevonden' }
}
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const postUrl = `${siteUrl}/blog/serie/${serieId}/${slug}`
// Use post-specific image if provided, otherwise fall back to default
const ogImageUrl = post.frontmatter.image
? `${siteUrl}${post.frontmatter.image.startsWith('/') ? '' : '/'}${post.frontmatter.image}`
: `${siteUrl}/og-blog-default.png`
return {
title: `${post.frontmatter.title} - ${series.title}`,
description: post.frontmatter.description,
authors: [{ name: 'Colin van der Heijden', url: 'https://ikbenlit.nl' }],
keywords: post.frontmatter.tags || [],
openGraph: {
title: post.frontmatter.title,
description: post.frontmatter.description,
type: 'article',
publishedTime: post.frontmatter.date,
authors: ['Colin van der Heijden'],
siteName: 'AI Speedrun',
locale: 'nl_NL',
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: post.frontmatter.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.frontmatter.title,
description: post.frontmatter.description,
images: [ogImageUrl],
},
alternates: {
canonical: postUrl,
},
}
}
@@ -55,6 +84,77 @@ export default async function PostPage({ params }: PostPageProps) {
const navigation = await getSeriesNavigation(serieId, slug)
const { frontmatter, content, readingTime } = post
// Generate Article structured data for SEO
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const postUrl = `${siteUrl}/blog/serie/${serieId}/${slug}`
// Use post-specific image if provided, otherwise fall back to default
const articleImage = frontmatter.image
? `${siteUrl}${frontmatter.image.startsWith('/') ? '' : '/'}${frontmatter.image}`
: `${siteUrl}/og-blog-default.png`
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: frontmatter.title,
description: frontmatter.description,
image: articleImage,
datePublished: frontmatter.date,
dateModified: frontmatter.date,
author: {
'@type': 'Person',
name: 'Colin van der Heijden',
url: 'https://ikbenlit.nl',
},
publisher: {
'@type': 'Organization',
name: 'AI Speedrun',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/images/aispeedrun-logo.webp`,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': postUrl,
},
articleSection: series.title,
keywords: frontmatter.tags?.join(', ') || '',
wordCount: content.split(/\s+/).length,
timeRequired: `PT${readingTime}M`,
}
// Generate BreadcrumbList structured data
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: siteUrl,
},
{
'@type': 'ListItem',
position: 2,
name: 'Blog',
item: `${siteUrl}/blog`,
},
{
'@type': 'ListItem',
position: 3,
name: series.title,
item: `${siteUrl}/blog/serie/${serieId}`,
},
{
'@type': 'ListItem',
position: 4,
name: frontmatter.title,
item: postUrl,
},
],
}
const colorStyles = {
teal: {
badge: 'bg-teal-100 text-teal-700 border-teal-200',
@@ -74,6 +174,16 @@ export default async function PostPage({ params }: PostPageProps) {
return (
<div className="min-h-screen bg-white pb-16">
{/* Structured Data for SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
/>
<article className="max-w-3xl mx-auto px-4 md:px-8">
{/* Back link */}
<div className="pt-20 md:pt-24 mb-6">

View File

@@ -25,9 +25,27 @@ export async function generateMetadata({ params }: SeriesPageProps) {
return { title: 'Serie niet gevonden' }
}
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const seriesUrl = `${siteUrl}/blog/serie/${serieId}`
return {
title: `${series.title} - Blog`,
title: `${series.title} - Blog | AI Speedrun`,
description: series.description,
openGraph: {
title: series.title,
description: series.description,
type: 'website',
siteName: 'AI Speedrun',
locale: 'nl_NL',
},
twitter: {
card: 'summary',
title: series.title,
description: series.description,
},
alternates: {
canonical: seriesUrl,
},
}
}

View File

@@ -7,6 +7,7 @@
import type { MetadataRoute } from 'next'
import { getAllReleases } from '@/lib/mdx/documentatie'
import { getAllPosts, getAllSeries } from '@/lib/mdx/blog'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
@@ -27,6 +28,29 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}
})
// Fetch all blog posts dynamically
const posts = await getAllPosts()
const postUrls: MetadataRoute.Sitemap = posts.map((post) => {
const postDate = new Date(post.frontmatter.date)
const isValidDate = !isNaN(postDate.getTime())
return {
url: `${baseUrl}/blog/serie/${post.seriesId}/${post.slug}`,
lastModified: isValidDate ? postDate : currentDate,
changeFrequency: 'monthly',
priority: 0.7,
}
})
// Fetch all blog series
const series = await getAllSeries()
const seriesUrls: MetadataRoute.Sitemap = series.map((serie) => ({
url: `${baseUrl}/blog/serie/${serie.id}`,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 0.8,
}))
return [
{
url: baseUrl,
@@ -34,12 +58,20 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'weekly',
priority: 1.0,
},
{
url: `${baseUrl}/blog`,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 0.9,
},
{
url: `${baseUrl}/documentatie`,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 0.9,
},
...seriesUrls,
...postUrls,
...releaseUrls,
{
url: `${baseUrl}/contact`,