Skip to content

Commit c4e26e5

Browse files
committed
Add SEO improvements: RSS feed, tag pages, breadcrumbs, OG images, and schema enhancements
- Fix sitemap lastModified to use actual post dates instead of build time - Add dateModified support to blog post schema and OpenGraph metadata - Create RSS feed at /feed.xml with 1-hour cache - Create blog tag pages at /blog/tag/[tag] and convert tag spans to links - Add related posts section to blog post pages - Add visual Breadcrumbs component to all pages with breadcrumb schema - Create dynamic OG images for blog posts using Next.js ImageResponse - Add WebApplication schema to Map and Observer pages
1 parent 472996c commit c4e26e5

File tree

20 files changed

+585
-57
lines changed

20 files changed

+585
-57
lines changed

src/app/about/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from 'next';
22
import Link from 'next/link';
33
import JsonLd from '@/components/JsonLd';
4+
import Breadcrumbs from '@/components/Breadcrumbs';
45
import { generateBreadcrumbSchema } from '@/lib/schemas/breadcrumb';
56
import { BASE_URL } from '@/lib/constants';
67

@@ -32,6 +33,7 @@ export default function AboutPage() {
3233
{/* Hero Section */}
3334
<section className="px-6 py-16 md:py-24 text-center">
3435
<div className="max-w-4xl mx-auto">
36+
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'About' }]} />
3537
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 text-foreground">
3638
About Denver MeshCore
3739
</h1>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { ImageResponse } from 'next/og';
2+
import { getPostBySlug, getPostSlugs } from '@/lib/blog';
3+
4+
export const alt = 'Denver MeshCore Blog Post';
5+
export const size = { width: 1200, height: 630 };
6+
export const contentType = 'image/png';
7+
8+
export function generateStaticParams() {
9+
const slugs = getPostSlugs();
10+
return slugs.map((slug) => ({ slug }));
11+
}
12+
13+
export default async function OGImage({ params }: { params: Promise<{ slug: string }> }) {
14+
const { slug } = await params;
15+
const post = getPostBySlug(slug);
16+
17+
const title = post?.title || 'Denver MeshCore Blog';
18+
const readingTime = post?.readingTime || '';
19+
20+
return new ImageResponse(
21+
(
22+
<div
23+
style={{
24+
display: 'flex',
25+
flexDirection: 'column',
26+
justifyContent: 'space-between',
27+
width: '100%',
28+
height: '100%',
29+
padding: '60px',
30+
background: 'linear-gradient(135deg, #0a0f1a 0%, #111827 50%, #0a1628 100%)',
31+
fontFamily: 'system-ui, sans-serif',
32+
}}
33+
>
34+
{/* Top: branding */}
35+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
36+
<div
37+
style={{
38+
width: '40px',
39+
height: '40px',
40+
borderRadius: '8px',
41+
background: 'linear-gradient(135deg, #22c55e, #16a34a)',
42+
display: 'flex',
43+
alignItems: 'center',
44+
justifyContent: 'center',
45+
}}
46+
>
47+
<div
48+
style={{
49+
width: '20px',
50+
height: '20px',
51+
borderRadius: '50%',
52+
backgroundColor: 'white',
53+
}}
54+
/>
55+
</div>
56+
<span style={{ color: '#9ca3af', fontSize: '24px', fontWeight: 500 }}>
57+
Denver MeshCore
58+
</span>
59+
</div>
60+
61+
{/* Center: title */}
62+
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
63+
<h1
64+
style={{
65+
fontSize: title.length > 60 ? '48px' : '56px',
66+
fontWeight: 700,
67+
color: '#ffffff',
68+
lineHeight: 1.2,
69+
margin: 0,
70+
maxWidth: '1000px',
71+
}}
72+
>
73+
{title}
74+
</h1>
75+
</div>
76+
77+
{/* Bottom: metadata */}
78+
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
79+
<span style={{ color: '#22c55e', fontSize: '20px', fontWeight: 600 }}>
80+
denvermc.com/blog
81+
</span>
82+
{readingTime && (
83+
<span style={{ color: '#6b7280', fontSize: '20px' }}>
84+
{readingTime}
85+
</span>
86+
)}
87+
</div>
88+
</div>
89+
),
90+
{ ...size }
91+
);
92+
}

src/app/blog/[slug]/page.tsx

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Metadata } from 'next';
22
import { notFound } from 'next/navigation';
33
import Link from 'next/link';
4-
import { getPostBySlug, getPostSlugs } from '@/lib/blog';
4+
import { getPostBySlug, getPostSlugs, getRelatedPosts } from '@/lib/blog';
55
import { generateBlogPostSchema } from '@/lib/schemas/blog';
6+
import { generateBreadcrumbSchema } from '@/lib/schemas/breadcrumb';
67
import { BASE_URL } from '@/lib/constants';
78
import JsonLd from '@/components/JsonLd';
9+
import Breadcrumbs from '@/components/Breadcrumbs';
810
import { compileMDX } from 'next-mdx-remote/rsc';
911
import remarkGfm from 'remark-gfm';
1012
import rehypeSlug from 'rehype-slug';
@@ -40,6 +42,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
4042
description: post.excerpt,
4143
type: 'article',
4244
publishedTime: post.date,
45+
modifiedTime: post.dateModified || post.date,
4346
authors: [post.author],
4447
url: `${BASE_URL}/blog/${slug}`,
4548
},
@@ -89,50 +92,42 @@ export default async function BlogPostPage({ params }: PageProps) {
8992
title: post.title,
9093
excerpt: post.excerpt,
9194
date: post.date,
95+
dateModified: post.dateModified,
9296
author: post.author,
9397
slug: post.slug,
9498
});
9599

100+
const relatedPosts = getRelatedPosts(post.slug, post.tags, 3);
101+
102+
const breadcrumbSchema = generateBreadcrumbSchema([
103+
{ name: 'Home', url: BASE_URL },
104+
{ name: 'Blog', url: `${BASE_URL}/blog` },
105+
{ name: post.title, url: `${BASE_URL}/blog/${post.slug}` },
106+
]);
107+
96108
return (
97109
<>
98110
<JsonLd data={blogPostSchema} />
111+
<JsonLd data={breadcrumbSchema} />
99112

100113
<article className="min-h-screen bg-background">
101114
{/* Header */}
102115
<header className="relative py-16 sm:py-24 overflow-hidden">
103116
<div className="absolute inset-0 bg-gradient-to-b from-mesh/5 to-transparent" />
104117
<div className="relative mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
105-
{/* Back link */}
106-
<Link
107-
href="/blog"
108-
className="inline-flex items-center text-mesh hover:text-mesh/80 mb-8 transition-colors"
109-
>
110-
<svg
111-
className="w-4 h-4 mr-2"
112-
fill="none"
113-
stroke="currentColor"
114-
viewBox="0 0 24 24"
115-
>
116-
<path
117-
strokeLinecap="round"
118-
strokeLinejoin="round"
119-
strokeWidth={2}
120-
d="M15 19l-7-7 7-7"
121-
/>
122-
</svg>
123-
Back to Blog
124-
</Link>
118+
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Blog', href: '/blog' }, { label: post.title }]} />
125119

126120
{/* Tags */}
127121
{post.tags.length > 0 && (
128122
<div className="flex flex-wrap gap-2 mb-4">
129123
{post.tags.map((tag) => (
130-
<span
124+
<Link
131125
key={tag}
132-
className="px-3 py-1 bg-mesh/10 text-mesh rounded-full text-sm"
126+
href={`/blog/tag/${encodeURIComponent(tag.toLowerCase())}`}
127+
className="px-3 py-1 bg-mesh/10 text-mesh rounded-full text-sm hover:bg-mesh/20 transition-colors"
133128
>
134129
{tag}
135-
</span>
130+
</Link>
136131
))}
137132
</div>
138133
)}
@@ -160,6 +155,30 @@ export default async function BlogPostPage({ params }: PageProps) {
160155
</div>
161156
</div>
162157

158+
{/* Related Posts */}
159+
{relatedPosts.length > 0 && (
160+
<section className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 pb-12">
161+
<h2 className="text-2xl font-bold text-foreground mb-6">Related Posts</h2>
162+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
163+
{relatedPosts.map((related) => (
164+
<Link
165+
key={related.slug}
166+
href={`/blog/${related.slug}`}
167+
className="group bg-card border border-border rounded-xl p-5 hover:border-mesh/50 transition-colors"
168+
>
169+
<h3 className="font-semibold text-foreground group-hover:text-mesh transition-colors mb-2 line-clamp-2">
170+
{related.title}
171+
</h3>
172+
<p className="text-sm text-foreground-muted line-clamp-2 mb-3">
173+
{related.excerpt}
174+
</p>
175+
<span className="text-xs text-foreground-muted">{related.readingTime}</span>
176+
</Link>
177+
))}
178+
</div>
179+
</section>
180+
)}
181+
163182
{/* Footer */}
164183
<footer className="border-t border-border py-12">
165184
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 text-center">

src/app/blog/page.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,6 @@ export default function BlogPage() {
102102
<div className="flex flex-wrap items-center gap-4 text-sm text-foreground-muted">
103103
<span>{formatDate(post.date)}</span>
104104
<span>{post.readingTime}</span>
105-
{post.tags.length > 0 && (
106-
<div className="flex flex-wrap gap-2">
107-
{post.tags.slice(0, 3).map((tag) => (
108-
<span
109-
key={tag}
110-
className="px-2 py-0.5 bg-mesh/10 text-mesh rounded-full text-xs"
111-
>
112-
{tag}
113-
</span>
114-
))}
115-
</div>
116-
)}
117105
</div>
118106
</div>
119107
<div className="hidden sm:block">
@@ -136,6 +124,19 @@ export default function BlogPage() {
136124
</div>
137125
</div>
138126
</Link>
127+
{post.tags.length > 0 && (
128+
<div className="flex flex-wrap gap-2 mt-3">
129+
{post.tags.map((tag) => (
130+
<Link
131+
key={tag}
132+
href={`/blog/tag/${encodeURIComponent(tag.toLowerCase())}`}
133+
className="px-2 py-0.5 bg-mesh/10 text-mesh rounded-full text-xs hover:bg-mesh/20 transition-colors"
134+
>
135+
{tag}
136+
</Link>
137+
))}
138+
</div>
139+
)}
139140
</article>
140141
))}
141142
</div>

0 commit comments

Comments
 (0)