How to Create a Next.js Blog - Part 3: Advanced Features

Add advanced features to your Next.js blog including related posts, share buttons, enhanced code blocks, and SEO optimization. Part 3 of our comprehensive tutorial series.

R
Raşit Apalak
12 min read

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.


Table of Contents


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

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, and CodeBlock
  • Enhanced generateMetadata() with Open Graph and X Card metadata
  • Added JSON-LD structured data for search engines
  • Added CodeBlock component to MDX for copy-to-clipboard functionality
  • Added ShareButtons component after the article
  • Added RelatedPosts component 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

Continue to Part 4 →


Troubleshooting

Issue: Share buttons not opening popup

Solution: Some browsers block popups. Make sure your site is served over HTTPS in production, and test in different browsers.

Issue: Code copy not working

Solution: The Clipboard API requires HTTPS (or localhost). Make sure you're testing on a secure connection in production.

Issue: SEO metadata not showing

Solution: Test your metadata using tools like:


Conclusion

You've successfully added professional features to your blog! Related posts keep readers engaged, share buttons help content spread, and SEO optimization improves discoverability.

Key Takeaways:

  • Related posts increase engagement and reduce bounce rate
  • Social sharing extends your content's reach
  • Code blocks with copy functionality improve developer experience
  • SEO optimization is crucial for organic traffic

Your blog is now feature-complete and ready for content! In Part 4, we'll show you how to migrate to a headless CMS for easier content management.

Share this post:

Related posts