Featured Post

Next.js Blog Tutorial: Building a Markdown Blog with SSG

Complete guide to implementing a blog in Next.js 15: markdown processing, static generation, dark mode, and professional typography. From concept to deployment.

Richard Joseph Porter
13 min read
nextjsblogmarkdownstatic-generationportfoliotypescript

When I decided to add a blog feature to my Next.js portfolio, I wanted something that would be fast, maintainable, and seamlessly integrated with the existing design. This article documents the complete implementation process, from initial planning to the final polished result.

Why Add a Blog to a Portfolio?

Before diving into the technical implementation, let's consider why a blog feature adds value to a developer portfolio:

  • Thought Leadership: Demonstrates expertise and keeps you visible in the community
  • SEO Benefits: Fresh content helps improve search engine rankings
  • Client Attraction: Shows your problem-solving process and communication skills
  • Knowledge Sharing: Contributes to the developer community
  • Personal Branding: Establishes your voice and technical perspective

Project Requirements and Planning

Core Requirements

I established these requirements for the blog feature:

  1. Performance First: Static generation with excellent Core Web Vitals
  2. Seamless Integration: Match existing portfolio design and dark mode
  3. Developer Experience: Easy content creation with markdown
  4. Professional Typography: Excellent reading experience
  5. SEO Optimization: Proper metadata and structured data
  6. Responsive Design: Perfect on all devices
  7. Accessibility: WCAG 2.1 AA compliance

Technology Stack Decision

After evaluating various options, I chose:

const blogTechStack = {
  contentManagement: "Markdown files with frontmatter",
  processing: "Unified.js ecosystem (remark/rehype)",
  styling: "Tailwind CSS + Typography plugin",
  generation: "Next.js Static Site Generation (SSG)",
  highlighting: "rehype-highlight + highlight.js",
  validation: "Zod for type safety",
  performance: "Built-in Next.js optimizations"
};

Architecture Overview

The blog architecture follows Next.js 15 best practices:

src/
├── app/
│   ├── blog/
│   │   ├── page.tsx              # Blog listing
│   │   └── [slug]/
│   │       └── page.tsx          # Individual posts
├── components/blog/
│   ├── BlogCard.tsx              # Post preview cards
│   ├── BlogContent.tsx           # Markdown renderer
│   ├── BlogHeader.tsx            # Post metadata
│   └── BlogNavigation.tsx        # Previous/next links
├── lib/
│   ├── blog.ts                   # Content utilities
│   └── markdown.ts               # Processing pipeline
└── content/blog/                 # Markdown files
    ├── post-1.md
    ├── post-2.md
    └── post-3.md

Phase 1: Content Management System

Markdown with Frontmatter

I chose markdown with YAML frontmatter for content management due to its simplicity and developer-friendly nature:

---
title: "Your Post Title"
excerpt: "Brief description for previews and SEO"
date: "2025-07-19"
tags: ["nextjs", "typescript", "tutorial"]
featured: true
author: "Richard Joseph Porter"
---

# Your Content Here

Regular markdown content with full formatting support...

Content Utilities Implementation

The blog utilities handle all content operations:

// src/lib/blog.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import readingTime from 'reading-time';

export interface BlogPost {
  slug: string;
  title: string;
  excerpt: string;
  content: string;
  date: string;
  tags: string[];
  readingTime: string;
  featured?: boolean;
  author?: string;
}

export async function getAllPosts(): Promise<BlogPost[]> {
  const slugs = getPostSlugs();
  const posts = await Promise.all(
    slugs.map(slug => getPostBySlug(slug))
  );
  
  return posts
    .filter((post): post is BlogPost => post !== null)
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}

export async function getPostBySlug(slug: string): Promise<BlogPost | null> {
  try {
    const fullPath = path.join(postsDirectory, `${slug}.md`);
    if (!fs.existsSync(fullPath)) return null;
    
    const fileContents = fs.readFileSync(fullPath, 'utf8');
    const { data, content } = matter(fileContents);
    const stats = readingTime(content);

    return {
      slug,
      title: data.title || '',
      excerpt: data.excerpt || '',
      content,
      date: data.date || '',
      tags: data.tags || [],
      readingTime: stats.text,
      featured: data.featured || false,
      author: data.author || 'Richard Joseph Porter',
    };
  } catch (error) {
    console.error(`Error reading post ${slug}:`, error);
    return null;
  }
}

Phase 2: Markdown Processing Pipeline

The Challenge

Markdown processing in modern web applications requires careful consideration of:

  • Syntax highlighting for code blocks
  • HTML sanitization for security
  • Custom element handling (tables, blockquotes, etc.)
  • Performance optimization for build time

Unified.js Implementation

I implemented a robust processing pipeline using the unified.js ecosystem:

// src/lib/markdown.ts
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeHighlight from 'rehype-highlight';
import rehypeStringify from 'rehype-stringify';

export async function markdownToHtml(markdown: string): Promise<string> {
  const result = await remark()
    .use(remarkGfm)                               // GitHub Flavored Markdown
    .use(remarkRehype, { allowDangerousHtml: true })  // Markdown → HTML
    .use(rehypeHighlight)                         // Syntax highlighting
    .use(rehypeStringify, { allowDangerousHtml: true }) // HTML output
    .process(markdown);
  
  return result.toString();
}

export function addIdsToHeadings(html: string): string {
  return html.replace(
    /<h([1-6])([^>]*)>([^<]+)<\/h[1-6]>/g,
    (match, level, attributes, content) => {
      const id = content
        .toLowerCase()
        .replace(/[^\w\s-]/g, '')
        .replace(/\s+/g, '-')
        .replace(/-+/g, '-')
        .trim();
      
      return `<h${level}${attributes} id="${id}">${content}</h${level}>`;
    }
  );
}

Why This Pipeline?

  1. remark-gfm: Adds support for tables, task lists, and strikethrough
  2. remarkRehype: Properly converts markdown AST to HTML AST
  3. rehypeHighlight: Provides syntax highlighting without client-side JavaScript
  4. ID generation: Enables smooth scrolling to headings

Phase 3: Next.js Integration

Static Site Generation (SSG)

The blog leverages Next.js SSG for optimal performance:

// src/app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    return { title: 'Post Not Found | Richard Joseph Porter' };
  }

  return {
    title: `${post.title} | Richard Joseph Porter`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.date,
      authors: [post.author || 'Richard Joseph Porter'],
      tags: post.tags,
    },
  };
}

Blog Listing Page

The listing page showcases all posts with filtering capabilities:

// src/app/blog/page.tsx
export const metadata: Metadata = {
  title: 'Blog | Richard Joseph Porter',
  description: 'Technical articles and insights about web development...',
  openGraph: {
    title: 'Blog | Richard Joseph Porter',
    description: 'Technical articles and insights...',
    type: 'website',
  },
};

export default async function BlogPage() {
  const posts = await getAllPosts();

  return (
    <main className="min-h-screen page-transition bg-gray-50 dark:bg-gray-900">
      <Navigation />
      
      <div className="pt-20">
        <section className="py-20 bg-white dark:bg-gray-900">
          <div className="container mx-auto px-6">
            <div className="text-center mb-16">
              <h1 className="text-4xl md:text-5xl font-bold text-gray-800 dark:text-gray-100 mb-6">
                Technical Blog
              </h1>
              <p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto leading-relaxed">
                Insights and deep dives into modern web development...
              </p>
            </div>

            <div className="grid lg:grid-cols-2 gap-8">
              {posts.map((post, index) => (
                <BlogCard 
                  key={post.slug} 
                  post={post} 
                  featured={post.featured}
                  index={index}
                />
              ))}
            </div>
          </div>
        </section>
      </div>
    </main>
  );
}

Phase 4: Component Architecture

BlogCard Component

The BlogCard provides an engaging preview of each post:

// src/components/blog/BlogCard.tsx
'use client';

import { useState } from 'react';
import Link from 'next/link';
import { BlogPost } from '@/lib/blog';

export default function BlogCard({ post, featured = false, index = 0 }: BlogCardProps) {
  const [isHovered, setIsHovered] = useState(false);

  return (
    <Link href={`/blog/${post.slug}`}>
      <article 
        onMouseEnter={() => setIsHovered(true)}
        onMouseLeave={() => setIsHovered(false)}
        className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 
          rounded-xl shadow-lg overflow-hidden hover:shadow-2xl hover:-translate-y-2 
          hover:scale-[1.02] transition-all duration-500 ease-out cursor-pointer group ${
          featured ? 'ring-2 ring-blue-500 dark:ring-blue-400' : ''
        } ${
          index % 2 === 0 ? 'animate-fadeInUp' : 'animate-fadeInUp-delayed'
        }`}
      >
        {featured && (
          <div className="bg-gradient-to-r from-blue-500 to-purple-600 px-6 py-3">
            <span className="text-white font-semibold text-sm">Featured Post</span>
          </div>
        )}

        <div className="p-8">
          <h2 className="text-2xl font-semibold mb-4 text-gray-800 dark:text-gray-100 
            group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
            {post.title}
          </h2>

          <p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed line-clamp-3">
            {post.excerpt}
          </p>

          {/* Metadata, tags, and CTA */}
        </div>
      </article>
    </Link>
  );
}

BlogContent Component

The most complex component handles markdown rendering with proper typography:

// src/components/blog/BlogContent.tsx
export default function BlogContent({ content }: BlogContentProps) {
  const proseClasses = `
    prose prose-lg prose-gray dark:prose-invert max-w-none blog-content
    prose-headings:font-semibold prose-headings:text-gray-800 dark:prose-headings:text-gray-100
    prose-h1:text-4xl prose-h1:mb-8 prose-h1:mt-12
    prose-h2:text-3xl prose-h2:mb-6 prose-h2:mt-10 prose-h2:border-b 
    prose-h2:border-gray-200 dark:prose-h2:border-gray-700 prose-h2:pb-3
    // ... additional prose classes
  `.replace(/\s+/g, ' ').trim();

  return (
    <div>
      <div 
        dangerouslySetInnerHTML={{ __html: content }}
        className={proseClasses}
      />
      
      <style jsx global>{`
        /* Custom CSS overrides for perfect typography */
        .blog-content pre {
          background: #1e293b !important;
          color: #e2e8f0 !important;
          border-radius: 0.5rem !important;
          padding: 1.5rem !important;
        }
        
        .blog-content ul {
          list-style-type: disc !important;
          margin: 1.5rem 0 !important;
          padding-left: 1.5rem !important;
        }
        
        .blog-content h2 {
          border-bottom: 1px solid rgb(229 231 235) !important;
          padding-bottom: 0.75rem !important;
        }
        
        /* Additional styling rules */
      `}</style>
    </div>
  );
}

Phase 5: Typography and Design Integration

The Typography Challenge

One of the biggest challenges was achieving perfect typography that matches the portfolio's design. The solution involved:

  1. Tailwind Typography Plugin: Provides excellent base styles
  2. Custom CSS Overrides: Fine-tune spacing and colors
  3. Dark Mode Integration: Ensure proper contrast in both themes
  4. Code Block Styling: Professional syntax highlighting

Key Typography Decisions

/* Heading hierarchy with proper spacing */
.blog-content h1 { font-size: 2.25rem; margin-top: 3rem; }
.blog-content h2 { font-size: 1.875rem; border-bottom: 1px solid; }
.blog-content h3 { font-size: 1.5rem; }

/* List styling for better readability */
.blog-content ul { list-style-type: disc; padding-left: 1.5rem; }
.blog-content li { margin: 0.5rem 0; line-height: 1.75; }

/* Code blocks with professional styling */
.blog-content pre {
  background: #1e293b;
  color: #e2e8f0;
  border-radius: 0.5rem;
  padding: 1.5rem;
}

Phase 6: Navigation Integration

Seamless Portfolio Integration

The blog needed to integrate seamlessly with the existing navigation:

// Updated Navigation component
const navItems: NavItem[] = [
  { id: 'home', label: 'Home', section: 'hero' },
  { id: 'about', label: 'About', section: 'about' },
  { id: 'experience', label: 'Experience', section: 'experience' },
  { id: 'projects', label: 'Projects', section: 'projects' },
  { id: 'skills', label: 'Skills', section: 'skills' },
  { id: 'testimonials', label: 'Testimonials', section: 'testimonials' },
  { id: 'blog', label: 'Blog', href: '/blog' },        // New blog link
  { id: 'contact', label: 'Contact', section: 'contact' },
];

Context-Aware Navigation

The navigation adapts based on the current page:

// Determine background based on page context
const shouldUseTransparentBg = pathname === '/' && !isScrolled;
const navBgClass = shouldUseTransparentBg 
  ? 'bg-transparent' 
  : 'bg-white/90 dark:bg-gray-900/90 backdrop-blur-md shadow-lg';

// Text colors adapt to background
const textColorClass = shouldUseTransparentBg 
  ? 'text-white' 
  : 'text-gray-800 dark:text-white';

Phase 7: Performance Optimization

Build-Time Generation

All blog content is generated at build time for optimal performance:

Route (app)                              Size    First Load JS
├ ○ /blog                              1.31 kB       107 kB
└ ● /blog/[slug]                       4.32 kB       110 kB
    ├ /blog/building-modern-portfolio-nextjs-15
    ├ /blog/implementing-dark-mode-tailwind-css
    └ /blog/contact-form-security-best-practices

Performance Metrics

The implementation achieves excellent Core Web Vitals:

  • First Contentful Paint: <1.5 seconds
  • Largest Contentful Paint: <2.5 seconds
  • Cumulative Layout Shift: <0.1
  • Bundle Size Impact: <5KB additional JavaScript

SEO Optimization

Each blog post includes comprehensive metadata:

export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);

  return {
    title: `${post.title} | Richard Joseph Porter`,
    description: post.excerpt,
    keywords: post.tags,
    authors: [{ name: post.author }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.date,
      authors: [post.author],
      tags: post.tags,
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
    },
  };
}

Challenges and Solutions

Challenge 1: Markdown Processing

Problem: Getting the right combination of plugins for proper HTML output with syntax highlighting.

Solution: Used the unified.js ecosystem with the correct plugin order:

  • remark → remarkGfm → remarkRehype → rehypeHighlight → rehypeStringify

Challenge 2: Typography Integration

Problem: Tailwind Typography prose classes weren't applying correctly to dynamically generated HTML.

Solution: Applied prose classes directly to the content container and used CSS overrides with !important for specificity.

Challenge 3: Navigation Context

Problem: Portfolio navigation wasn't visible on blog pages due to transparent background designed for hero sections.

Solution: Made navigation context-aware using usePathname() to adapt background and text colors based on the current page.

Challenge 4: Dark Mode Consistency

Problem: Ensuring all blog elements properly support dark mode, especially syntax-highlighted code blocks.

Solution: Comprehensive CSS overrides with dark mode variants for all typography elements. The implementation builds on the same dark mode system used throughout the portfolio, ensuring consistent theming across all pages including contact forms and other interactive features.

Results and Impact

Technical Achievements

  • Perfect Lighthouse Scores: 95+ across all metrics
  • Static Generation: All content pre-rendered for optimal performance
  • SEO Optimized: Proper metadata and structured data
  • Accessible: WCAG 2.1 AA compliance maintained
  • Responsive: Excellent experience on all devices

Developer Experience

  • Simple Content Creation: Write markdown, commit, deploy
  • Type Safety: Full TypeScript integration with Zod validation
  • Hot Reloading: Instant preview during development
  • Maintainable: Clean separation of concerns and modular architecture

User Experience

  • Fast Loading: Sub-2-second page loads
  • Professional Design: Consistent with portfolio branding
  • Excellent Typography: Optimized for readability
  • Smooth Navigation: Seamless integration with existing site

Future Enhancements

While the current implementation is fully functional, here are potential improvements:

Content Management

  • CMS Integration: Add Sanity or Contentful for non-technical content creators
  • Draft Mode: Preview unpublished posts
  • Content Scheduling: Automated publishing at specified times

User Engagement

  • Search Functionality: Full-text search across all posts
  • Tag Filtering: Browse posts by technology or topic
  • Related Posts: Algorithmic suggestions based on tags and content
  • Reading Progress: Visual indicator of reading completion

Analytics and Insights

  • View Tracking: Monitor post popularity
  • Reading Time Analytics: Track completion rates
  • Social Sharing: Integration with social media platforms
  • RSS Feed: Syndication for blog followers

Advanced Features

  • Comments System: Engage with readers (using giscus or similar)
  • Newsletter Integration: Email subscriptions for new posts
  • Series Support: Multi-part article series
  • Code Playground: Interactive code examples

Lessons Learned

1. Start with Content Strategy

Before writing code, clearly define:

  • Target audience and their needs
  • Content types and publishing frequency
  • SEO goals and keyword strategy
  • Integration requirements with existing systems

2. Choose the Right Abstraction Level

For a developer portfolio blog:

  • Markdown provides the perfect balance of simplicity and power
  • File-based content management reduces complexity
  • Static generation ensures excellent performance
  • Git-based workflow integrates with development practices

3. Performance is Non-Negotiable

  • Measure early and often with Lighthouse
  • Optimize images and consider next/image for better performance
  • Minimize JavaScript bundle size impact
  • Leverage Next.js built-in optimizations

4. Typography Matters

  • Reading experience significantly impacts user engagement
  • Consistent styling maintains professional appearance
  • Proper hierarchy improves content scanability
  • Dark mode support is essential for modern applications

Conclusion

Building a blog feature for a Next.js portfolio requires careful consideration of performance, user experience, and maintainability. The implementation documented here achieves:

  • Excellent Performance: Static generation with minimal JavaScript overhead
  • Professional Design: Seamless integration with existing portfolio aesthetics
  • Developer-Friendly: Simple markdown-based content creation workflow
  • Future-Proof: Modular architecture that supports future enhancements

The key to success was focusing on the fundamentals: fast loading times, excellent typography, and a maintainable codebase. By leveraging Next.js 15's capabilities and the unified.js ecosystem, we created a blog that enhances the portfolio without compromising its core strengths.

Whether you're building a personal blog or adding content capabilities to a client project, these patterns and approaches provide a solid foundation for a modern, performant blog implementation.


Feel free to adapt these patterns for your own projects and reach out if you have questions about the implementation.

Richard Joseph Porter - Professional headshot

Richard Joseph Porter

Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.