DEV Community

Raşit
Raşit

Posted on • Originally published at elmapicms.com

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

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

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)
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Date Formatting

We already have formatDate() in utils.ts. Make sure it's used consistently:

{formatDate(post.date)}  {readingTime} min read
Enter fullscreen mode Exit fullscreen mode

Step 7: Deployment Considerations

Environment Variables

Create .env.local for development:

NEXT_PUBLIC_SITE_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

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'],
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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)