Polishing Your Blog with Advanced Features
In Part 1 and Part 2, we built a functional blog with search and categories. Now we'll add professional touches: related posts, social sharing, enhanced code blocks, and comprehensive SEO.
What You'll Build
By the end of Part 3, you'll have:
- Related posts that suggest similar content
- Social share buttons for X, Facebook, and LinkedIn
- Code blocks with copy-to-clipboard functionality
- Comprehensive SEO with Open Graph and X Cards
- Reading time calculation
- Author information display
Prerequisites
Before starting, make sure you've completed Part 1 and Part 2, and have:
- Individual post pages working
- Table of contents implemented
- Search and categories functional
Step 1: Related Posts Feature
Related posts help readers discover more content. We'll show posts from the same category, with a fallback to other posts.
Implementing getRelatedPosts()
Update lib/posts.ts:
export async function getRelatedPosts(currentSlug: string, limit: number = 3): Promise<PostMatter[]> {
const allPosts = await getSortedPostsData()
const currentPost = allPosts.find((post) => post.slug === currentSlug)
if (!currentPost) {
return []
}
// Get all posts excluding the current one
const otherPosts = allPosts.filter((post) => post.slug !== currentSlug)
if (otherPosts.length === 0) {
return []
}
// Separate posts by category match
const sameCategoryPosts = otherPosts.filter(
(post) => post.category && post.category === currentPost.category
)
const differentCategoryPosts = otherPosts.filter(
(post) => !post.category || post.category !== currentPost.category
)
// Shuffle arrays for randomness
const shuffle = <T,>(array: T[]): T[] => {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
const shuffledSameCategory = shuffle(sameCategoryPosts)
const shuffledDifferentCategory = shuffle(differentCategoryPosts)
// Mix posts: prefer same category but ensure variety
const relatedPosts: PostMatter[] = []
// Take up to 2 posts from same category (if available)
const sameCategoryCount = Math.min(2, shuffledSameCategory.length, limit)
relatedPosts.push(...shuffledSameCategory.slice(0, sameCategoryCount))
// Fill remaining slots with random posts from different categories
const remaining = limit - relatedPosts.length
if (remaining > 0 && shuffledDifferentCategory.length > 0) {
relatedPosts.push(...shuffledDifferentCategory.slice(0, remaining))
}
// If we still don't have enough, fill with any remaining posts
const stillRemaining = limit - relatedPosts.length
if (stillRemaining > 0) {
const allRemaining = [
...shuffledSameCategory.slice(sameCategoryCount),
...shuffledDifferentCategory.slice(remaining)
]
relatedPosts.push(...allRemaining.slice(0, stillRemaining))
}
return relatedPosts.slice(0, limit)
}
This function:
- Finds posts in the same category first
- Falls back to other categories if needed
- Randomizes results for variety
- Limits the number of related posts
Creating the RelatedPosts Component
Create components/related-posts.tsx:
import Link from 'next/link'
import { PostMatter } from '@/lib/posts'
import { formatDate } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { ArrowRight } from 'lucide-react'
interface RelatedPostsProps {
posts: PostMatter[]
}
export function RelatedPosts({ posts }: RelatedPostsProps) {
if (posts.length === 0) {
return null
}
return (
<section className="mt-8 pt-8 border-t">
<h2 className="text-2xl font-bold mb-8">Related Posts</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="group block"
>
<article className="h-full flex flex-col rounded-lg border hover:border-primary/50 transition-colors overflow-hidden">
<div className="flex-1 p-4 flex flex-col">
<div className="flex items-center gap-2 mb-2">
{post.category && (
<Badge variant="secondary" className="text-xs">
{post.category}
</Badge>
)}
<time className="text-xs text-muted-foreground">
{formatDate(post.date)}
</time>
</div>
<h3 className="text-lg font-semibold mb-2 group-hover:text-primary transition-colors line-clamp-2">
{post.title}
</h3>
{post.description && (
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 flex-1">
{post.description}
</p>
)}
<div className="flex items-center text-sm text-primary group-hover:underline mt-auto">
Read more
<ArrowRight className="ml-1 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</article>
</Link>
))}
</div>
</section>
)
}
Step 2: Share Buttons Component
Let's add social sharing buttons so readers can easily share your posts.
Creating ShareButtons Component
Create components/share-buttons.tsx:
"use client"
import { Button } from "@/components/ui/button"
import { LinkIcon, Copy, Check } from "lucide-react"
import { useState } from "react"
interface ShareButtonsProps {
title: string
url: string
description?: string
}
export function ShareButtons({ title, url, description }: ShareButtonsProps) {
const [copied, setCopied] = useState(false)
const handleShare = (platform: string) => {
let shareUrl = ""
switch (platform) {
case "x":
shareUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`
break
case "facebook":
shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`
break
case "linkedin":
shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
break
}
if (shareUrl) {
window.open(shareUrl, "_blank", "width=600,height=400")
}
}
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error("Failed to copy link:", err)
}
}
return (
<div className="flex items-center gap-2 pt-8 border-t">
<span className="text-sm font-medium text-muted-foreground mr-2">
Share this post:
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleShare("x")}
className="h-8 w-8 p-0"
aria-label="Share on X"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
className="h-4 w-4"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"></path>
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleShare("facebook")}
className="h-8 w-8 p-0"
aria-label="Share on Facebook"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
className="h-4 w-4"
fill="currentColor"
>
<path d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16 0 44.2 3.2 55.7 6.4v69.3c-6.1-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287v170.3C399.3 476.8 512 376 512 256z" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleShare("linkedin")}
className="h-8 w-8 p-0"
aria-label="Share on LinkedIn"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="h-4 w-4">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"></path>
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCopyLink}
className="h-8 w-8 p-0"
aria-label="Copy link"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<LinkIcon className="h-4 w-4" />
)}
</Button>
</div>
</div>
)
}
Step 3: Code Block Enhancement
Let's enhance code blocks with copy-to-clipboard functionality.
Creating CodeBlock Component
Create components/code-block.tsx:
'use client'
import React, { useState } from 'react'
import { Copy, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
children?: React.ReactNode
}
// Helper to extract text content from React nodes
function extractTextContent(node: React.ReactNode): string {
if (typeof node === 'string') {
return node
}
if (typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(extractTextContent).join('')
}
if (React.isValidElement(node)) {
const props = node.props as { children?: React.ReactNode }
if (props?.children) {
return extractTextContent(props.children)
}
}
return ''
}
export function CodeBlock({ className, children, ...props }: CodeBlockProps) {
const [copied, setCopied] = useState(false)
const codeContent = extractTextContent(children)
const handleCopy = async () => {
if (!codeContent) return
try {
await navigator.clipboard.writeText(codeContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy code:', err)
}
}
return (
<div className="relative group">
<pre
className={cn('relative', className)}
{...props}
>
{children}
{codeContent && (
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity bg-neutral-800/80 hover:bg-neutral-700/80 dark:bg-neutral-700/80 dark:hover:bg-neutral-600/80 text-neutral-200 hover:text-neutral-100 border border-neutral-700/50 dark:border-neutral-600/50"
onClick={handleCopy}
aria-label="Copy code"
>
{copied ? (
<Check className="h-4 w-4 text-green-400" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
)}
</pre>
</div>
)
}
This replaces all <pre> tags in MDX with our enhanced CodeBlock component.
Step 4: SEO Optimization
In this step, we'll add comprehensive SEO with Open Graph tags, X Cards, and JSON-LD structured data. These will all be integrated into the complete post page file below.
Key SEO features we'll add:
- Open Graph tags - For rich previews on Facebook and other platforms
- X Cards - For rich previews when shared on X
- Canonical URL - To prevent duplicate content issues
- JSON-LD structured data - For better search engine understanding
Step 5: Complete Post Page
Now let's put everything together. Replace your app/blog/[slug]/page.tsx with this complete file that includes all the features from this part:
import { getPostData, getAllPostSlugs, getRelatedPosts } from '@/lib/posts'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { extractHeadings } from '@/lib/toc'
import { TableOfContents } from '@/components/table-of-contents'
import { RelatedPosts } from '@/components/related-posts'
import { ShareButtons } from '@/components/share-buttons'
import { CodeBlock } from '@/components/code-block'
import rehypeSlug from 'rehype-slug'
type PageParams = Promise<{ slug: string }>
export async function generateMetadata({
params,
}: {
params: PageParams
}): Promise<Metadata> {
try {
const { slug } = await params
const post = await getPostData(slug)
const url = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/blog/${slug}`
const image = post.image
? `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}${post.image}`
: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/og-image.png`
return {
title: post.title,
description: post.description,
alternates: {
canonical: url,
},
openGraph: {
title: post.title,
description: post.description,
type: 'article',
url,
images: [
{
url: image,
width: 1200,
height: post.image ? 500 : 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
images: [image],
},
}
} catch (e) {
return {
title: 'Post not found',
description: 'The post you are looking for does not exist.',
}
}
}
export async function generateStaticParams() {
const posts = await getAllPostSlugs()
return posts.map((post) => ({
slug: post.slug,
}))
}
export default async function PostPage({ params }: { params: PageParams }) {
const { slug } = await params
try {
const post = await getPostData(slug)
const words = post.content.split(/\s+/g).filter(Boolean).length
const readingTime = Math.ceil(words / 200)
const tocItems = extractHeadings(post.content)
const relatedPosts = await getRelatedPosts(slug, 3)
const url = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/blog/${slug}`
const image = post.image
? `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}${post.image}`
: undefined
// JSON-LD structured data for SEO
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
image: image,
url: url,
datePublished: post.date,
mainEntityOfPage: url,
author: {
'@type': 'Person',
name: 'Your Name', // Replace with your name
},
publisher: {
'@type': 'Organization',
name: 'Your Blog Name', // Replace with your blog name
logo: {
'@type': 'ImageObject',
url: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/logo.svg`,
},
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="container mx-auto px-4 py-16">
<div className="flex gap-8">
<TableOfContents items={tocItems} />
<div className="flex-1 max-w-4xl">
<Link
href="/blog"
className="mb-4 flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to blog
</Link>
<h1 className="mb-2 text-3xl font-extrabold leading-tight tracking-tighter md:text-4xl">
{post.title}
</h1>
<p className="mb-6 text-sm text-muted-foreground">
{formatDate(post.date)} · {readingTime} min read
</p>
<article className="prose dark:prose-invert max-w-none">
<MDXRemote
source={post.content}
options={{
mdxOptions: {
rehypePlugins: [rehypeSlug],
},
}}
components={{
pre: CodeBlock,
}}
/>
</article>
<ShareButtons
title={post.title}
url={url}
description={post.description}
/>
<RelatedPosts posts={relatedPosts} />
</div>
</div>
</div>
</>
)
} catch (e) {
notFound()
}
}
What's new compared to Part 2:
- Added imports for
getRelatedPosts,RelatedPosts,ShareButtons, andCodeBlock - Enhanced
generateMetadata()with Open Graph and X Card metadata - Added JSON-LD structured data for search engines
- Added
CodeBlockcomponent to MDX for copy-to-clipboard functionality - Added
ShareButtonscomponent after the article - Added
RelatedPostscomponent at the end
Step 6: Additional Enhancements
Author Information
Add author display to post pages:
<div className="mb-8 flex items-center gap-3 text-sm text-muted-foreground">
<div className="h-8 w-8 rounded-full bg-gray-300 dark:bg-gray-700 flex items-center justify-center font-bold text-lg">
A
</div>
<span>By <span className="font-medium text-foreground">Your Name</span></span>
</div>
Date Formatting
We already have formatDate() in utils.ts. Make sure it's used consistently:
{formatDate(post.date)} • {readingTime} min read
Step 7: Deployment Considerations
Environment Variables
Create .env.local for development:
NEXT_PUBLIC_SITE_URL=http://localhost:3000
For production, set this to your actual domain.
Static Site Generation
Next.js automatically generates static pages at build time thanks to generateStaticParams(). This provides:
- Fast page loads
- Better SEO
- Reduced server costs
- CDN-friendly
Build Optimization
Your next.config.ts should already have:
compress: true,
images: {
formats: ['image/webp', 'image/avif'],
// ...
}
This ensures optimal performance.
What's Next
Congratulations! You've completed Part 3. You now have:
- Related posts feature
- Social share buttons
- Enhanced code blocks
- Comprehensive SEO
In Part 4, we'll show you how to:
- Migrate from MDX files to ElmapiCMS headless CMS
- Use the ElmapiCMS API for content
- Set up webhooks for automatic rebuilds
- Manage content through a user-friendly admin panel
Top comments (0)