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.
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:
- Performance First: Static generation with excellent Core Web Vitals
- Seamless Integration: Match existing portfolio design and dark mode
- Developer Experience: Easy content creation with markdown
- Professional Typography: Excellent reading experience
- SEO Optimization: Proper metadata and structured data
- Responsive Design: Perfect on all devices
- 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?
- remark-gfm: Adds support for tables, task lists, and strikethrough
- remarkRehype: Properly converts markdown AST to HTML AST
- rehypeHighlight: Provides syntax highlighting without client-side JavaScript
- 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:
- Tailwind Typography Plugin: Provides excellent base styles
- Custom CSS Overrides: Fine-tune spacing and colors
- Dark Mode Integration: Ensure proper contrast in both themes
- 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
Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.