Building a modern, performant blog that supports multiple languages while maintaining excellent SEO and user experience is a challenge many developers face. In this comprehensive guide, I'll walk you through implementing a bilingual blog system using MDX, Next.js 14's App Router, Tailwind CSS for styling, and Upstash Redis for view counting—the exact stack powering Nibi, a financial planning app for Canadian freelancers.
This isn't just another blog tutorial. We're building a production-ready blog with real-time view counting, automatic RSS generation, and a design inspired by Upstash's clean aesthetic—all while maintaining perfect bilingual support for English and French content.
Context
After launching Nibi, I needed a blog that could:
- Serve content in both English and French seamlessly
- Track article views without expensive analytics
- Load blazingly fast with excellent SEO
- Match our existing design system perfectly
- Support rich content with code blocks and interactive components
The solution? MDX for content flexibility, Upstash for free Redis-based view counting, and Next.js 14 for optimal performance. This guide shares the exact implementation, including the issues I encountered and how to solve them.
Prerequisites
Before diving in, ensure you have:
Development Environment
- Windows with WSL 2 or macOS/Linux
- Node.js 20+ installed (
node --version
) - npm or yarn package manager
- Git configured for version control
Existing Setup
- Next.js 14 project with App Router
- Tailwind CSS configured
- Dynamic routing with
[locale]
parameter - Upstash account (free tier is perfect) - Sign up here
Technical Knowledge
- Basic understanding of React and Next.js
- Familiarity with MDX or Markdown
- Basic TypeScript knowledge
- Understanding of dynamic routing in Next.js
🚀 Building a Blog That Actually Converts?
This guide shows you how to implement view counting, bilingual content, and SEO optimization that helped Nibi grow to 1,000+ monthly readers in just 3 months. Want the complete source code, advanced components, and exclusive tips that didn't make it into this article?
Get the Full Blog Blueprint + Source Code →
Complete Implementation Guide
Step 1: Install Dependencies and Configure MDX
First, let's install all required packages. Note that we'll use a simpler approach than next-mdx-remote
to avoid compilation issues:
bash
# Core MDX packages
npm install @next/mdx @mdx-js/loader @mdx-js/react
# Content utilities
npm install gray-matter reading-time marked
# MDX plugins for enhanced functionality
npm install rehype-autolink-headings rehype-slug remark-gfm rehype-prism-plus
# Typography plugin for beautiful prose
npm install @tailwindcss/typography
# Upstash for view counting
npm install @upstash/redis @upstash/ratelimit
# RSS generation
npm install rss
Step 2: Configure Next.js for MDX
Update your next.config.js
to support MDX files:
javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],
experimental: {
mdxRs: true,
},
}
const withMDX = require('@next/mdx')({
extension: /\.mdx?$/,
options: {
remarkPlugins: [require('remark-gfm')],
rehypePlugins: [
require('rehype-slug'),
require('rehype-autolink-headings'),
require('rehype-prism-plus'),
],
},
})
module.exports = withMDX(nextConfig)
Step 3: Set Up Upstash Redis
Create a .env.local
file and add your Upstash credentials:
bash
# Get these from your Upstash console
UPSTASH_REDIS_REST_URL=your_redis_url_here
UPSTASH_REDIS_REST_TOKEN=your_redis_token_here
Create lib/upstash.ts
for view counting:
typescript
import { Redis } from '@upstash/redis'
import { Ratelimit } from '@upstash/ratelimit'
// Create Redis instance
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
// Create rate limiter to prevent abuse
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '10 s'), // 5 requests per 10 seconds
})
export async function incrementViews(slug: string, ip: string) {
// Check rate limit
const { success } = await ratelimit.limit(ip)
if (!success) return null
// Increment and return new count
const key = `pageviews:blog:${slug}`
const views = await redis.incr(key)
return views
}
export async function getViews(slug: string): Promise<number> {
const key = `pageviews:blog:${slug}`
const views = await redis.get<number>(key)
return views || 0
}
export async function getAllViews(): Promise<Record<string, number>> {
const keys = await redis.keys('pageviews:blog:*')
if (keys.length === 0) return {}
const views = await redis.mget(...keys)
const result: Record<string, number> = {}
keys.forEach((key, index) => {
const slug = key.replace('pageviews:blog:', '')
result[slug] = views[index] || 0
})
return result
}
Step 4: Create Content Structure
Set up your content directory structure:
bash
# Create content directories
mkdir -p content/blog/fr content/blog/en
mkdir -p content/authors
# Create sample author data
cat > content/authors/authors.ts << 'EOF'
export const authors = {
'nibi-team': {
name: 'Nibi Team',
avatar: '/authors/nibi-team.jpg',
bio: {
en: 'Building financial tools for Canadian freelancers',
fr: 'Créer des outils financiers pour les freelances canadiens'
}
}
}
EOF
Step 5: Create MDX Utilities
Create lib/mdx.ts
for handling MDX files:
typescript
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { remark } from 'remark'
import html from 'remark-html'
const contentDirectory = path.join(process.cwd(), 'content', 'blog')
export interface BlogPost {
slug: string
title: string
excerpt: string
date: string
author: string
category: string
heroImage: string
readingTime: string
tags: string[]
content?: string
locale: string
}
export async function getPostSlugs(locale: string): Promise<string[]> {
const postsDirectory = path.join(contentDirectory, locale)
if (!fs.existsSync(postsDirectory)) {
return []
}
const filenames = fs.readdirSync(postsDirectory)
return filenames
.filter(filename => filename.endsWith('.mdx') || filename.endsWith('.md'))
.map(filename => filename.replace(/\.(mdx|md)$/, ''))
}
export async function getPostBySlug(slug: string, locale: string): Promise<BlogPost | null> {
const realSlug = slug.replace(/\.(mdx|md)$/, '')
const fullPath = path.join(contentDirectory, locale, `${realSlug}.mdx`)
if (!fs.existsSync(fullPath)) {
// Try .md extension
const mdPath = path.join(contentDirectory, locale, `${realSlug}.md`)
if (!fs.existsSync(mdPath)) {
return null
}
}
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
// Calculate reading time
const wordsPerMinute = 200
const words = content.split(/\s+/).length
const readingTime = Math.ceil(words / wordsPerMinute)
return {
slug: realSlug,
title: data.title || '',
excerpt: data.excerpt || '',
date: data.date || new Date().toISOString(),
author: data.author || 'Nibi Team',
category: data.category || 'General',
heroImage: data.heroImage || '/blog/default-hero.jpg',
readingTime: `${readingTime} min read`,
tags: data.tags || [],
content: content,
locale: locale
}
}
export async function getAllPosts(locale: string): Promise<BlogPost[]> {
const slugs = await getPostSlugs(locale)
const posts = await Promise.all(
slugs.map(slug => getPostBySlug(slug, locale))
)
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 getRelatedPosts(
currentSlug: string,
locale: string,
tags: string[],
limit: number = 3
): Promise<BlogPost[]> {
const allPosts = await getAllPosts(locale)
return allPosts
.filter(post => post.slug !== currentSlug)
.filter(post => post.tags.some(tag => tags.includes(tag)))
.slice(0, limit)
}
export async function generateTableOfContents(content: string) {
const headingRegex = /^(#{2,3})\s+(.+)$/gm
const headings = []
let match
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length
const text = match[2]
const id = text.toLowerCase().replace(/[^\w]+/g, '-')
headings.push({ level, text, id })
}
return headings
}
Step 6: Create Blog Components
BlogCard Component (components/blog/BlogCard.tsx
)
tsx
import Image from 'next/image'
import Link from 'next/link'
import { BlogPost } from '@/lib/mdx'
interface BlogCardProps {
post: BlogPost
locale: string
views?: number
}
export default function BlogCard({ post, locale, views }: BlogCardProps) {
return (
<Link href={`/${locale}/blog/${post.slug}`}>
<article className="group cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-white transition-all duration-200 hover:border-blue-500 hover:shadow-lg">
<div className="relative aspect-[16/9] overflow-hidden bg-gray-100">
<Image
src={post.heroImage}
alt={post.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
</div>
<div className="p-6">
<div className="mb-3 flex items-center gap-2">
<span className="inline-block rounded-full bg-gradient-to-r from-blue-500 to-blue-600 px-3 py-1 text-xs font-medium text-white">
{post.category}
</span>
{views !== undefined && (
<span className="text-xs text-gray-500">{views} views</span>
)}
</div>
<h2 className="mb-2 line-clamp-2 text-xl font-bold text-gray-900 transition-colors group-hover:text-blue-600">
{post.title}
</h2>
<p className="mb-4 line-clamp-3 text-gray-600">
{post.excerpt}
</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<span>{post.readingTime}</span>
</div>
</div>
</article>
</Link>
)
}
ViewCounter Component (components/blog/ViewCounter.tsx
)
tsx
'use client'
import { useEffect, useState } from 'react'
interface ViewCounterProps {
slug: string
increment?: boolean
}
export default function ViewCounter({ slug, increment = false }: ViewCounterProps) {
const [views, setViews] = useState<number | null>(null)
useEffect(() => {
async function fetchViews() {
try {
const endpoint = increment ? 'increment' : 'get'
const response = await fetch(`/api/views/${slug}?action=${endpoint}`)
const data = await response.json()
setViews(data.views)
} catch (error) {
console.error('Failed to fetch views:', error)
}
}
fetchViews()
}, [slug, increment])
if (views === null) return <span className="text-gray-500">...</span>
return (
<span className="text-gray-600">
{views.toLocaleString()} {views === 1 ? 'view' : 'views'}
</span>
)
}
Step 7: Create Blog Pages
Blog Index Page (app/[locale]/blog/page.tsx
)
tsx
import { Metadata } from 'next'
import { getAllPosts } from '@/lib/mdx'
import { getAllViews } from '@/lib/upstash'
import BlogCard from '@/components/blog/BlogCard'
interface BlogPageProps {
params: { locale: string }
}
export async function generateMetadata({ params }: BlogPageProps): Promise<Metadata> {
const { locale } = params
return {
title: locale === 'fr' ? 'Blog - Nibi' : 'Blog - Nibi',
description: locale === 'fr'
? 'Conseils et guides pour freelances au Québec'
: 'Tips and guides for Quebec freelancers',
}
}
export default async function BlogPage({ params }: BlogPageProps) {
const { locale } = params
const posts = await getAllPosts(locale)
const allViews = await getAllViews()
return (
<div className="container mx-auto px-6 py-12">
<div className="mb-12 text-center">
<h1 className="mb-4 text-4xl font-bold text-gray-900 md:text-5xl">
{locale === 'fr' ? 'Blog Nibi' : 'Nibi Blog'}
</h1>
<p className="text-xl text-gray-600">
{locale === 'fr'
? 'Guides, conseils et actualités pour les freelances'
: 'Guides, tips, and news for freelancers'}
</p>
</div>
{posts.length === 0 ? (
<p className="text-center text-gray-500">
{locale === 'fr' ? 'Aucun article disponible.' : 'No posts available.'}
</p>
) : (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<BlogCard
key={post.slug}
post={post}
locale={locale}
views={allViews[post.slug]}
/>
))}
</div>
)}
</div>
)
}
Article Page (app/[locale]/blog/[slug]/page.tsx
)
tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPostBySlug, getAllPosts, getRelatedPosts, generateTableOfContents } from '@/lib/mdx'
import { MDXRemote } from 'next-mdx-remote/rsc'
import ViewCounter from '@/components/blog/ViewCounter'
import RelatedPosts from '@/components/blog/RelatedPosts'
import TableOfContents from '@/components/blog/TableOfContents'
import ShareButtons from '@/components/blog/ShareButtons'
import NewsletterSignup from '@/components/blog/NewsletterSignup'
interface BlogPostPageProps {
params: { locale: string; slug: string }
}
export async function generateStaticParams() {
const locales = ['en', 'fr']
const paths = []
for (const locale of locales) {
const posts = await getAllPosts(locale)
for (const post of posts) {
paths.push({ locale, slug: post.slug })
}
}
return paths
}
export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
const post = await getPostBySlug(params.slug, params.locale)
if (!post) {
return { title: 'Post Not Found' }
}
return {
title: `${post.title} | Nibi Blog`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.heroImage],
type: 'article',
publishedTime: post.date,
},
}
}
// Custom MDX components
const components = {
h1: (props: any) => (
<h1 className="mb-6 mt-8 text-4xl font-bold text-gray-900" {...props} />
),
h2: (props: any) => (
<h2 className="mb-4 mt-8 text-3xl font-bold text-gray-900" {...props} />
),
h3: (props: any) => (
<h3 className="mb-3 mt-6 text-2xl font-semibold text-gray-900" {...props} />
),
p: (props: any) => (
<p className="mb-4 leading-relaxed text-gray-700" {...props} />
),
ul: (props: any) => (
<ul className="mb-4 list-inside list-disc space-y-2 text-gray-700" {...props} />
),
ol: (props: any) => (
<ol className="mb-4 list-inside list-decimal space-y-2 text-gray-700" {...props} />
),
blockquote: (props: any) => (
<blockquote className="my-6 border-l-4 border-blue-500 bg-blue-50 p-6 rounded-r-lg" {...props} />
),
code: (props: any) => (
<code className="rounded bg-gray-100 px-2 py-1 text-sm font-mono text-gray-800" {...props} />
),
pre: (props: any) => (
<pre className="my-6 overflow-x-auto rounded-lg bg-gray-900 p-4 text-sm" {...props} />
),
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { locale, slug } = params
const post = await getPostBySlug(slug, locale)
if (!post) {
notFound()
}
const relatedPosts = await getRelatedPosts(slug, locale, post.tags)
const tableOfContents = await generateTableOfContents(post.content || '')
return (
<article className="container mx-auto px-6 py-12">
{/* Hero Image */}
<div className="relative mb-8 aspect-[2/1] overflow-hidden rounded-2xl">
<img
src={post.heroImage}
alt={post.title}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
</div>
{/* Article Header */}
<header className="mb-8">
<div className="mb-4 flex items-center gap-4 text-sm text-gray-600">
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span>•</span>
<span>{post.readingTime}</span>
<span>•</span>
<ViewCounter slug={slug} increment />
</div>
<h1 className="mb-4 text-4xl font-bold text-gray-900 md:text-5xl">
{post.title}
</h1>
<p className="text-xl text-gray-600">{post.excerpt}</p>
</header>
<div className="flex gap-12">
{/* Main Content */}
<div className="flex-1">
<div className="prose prose-lg prose-blue max-w-none">
<MDXRemote source={post.content || ''} components={components} />
</div>
{/* Newsletter Signup */}
<div className="my-12">
<NewsletterSignup locale={locale} />
</div>
{/* Related Posts */}
{relatedPosts.length > 0 && (
<div className="mt-12">
<RelatedPosts posts={relatedPosts} locale={locale} />
</div>
)}
</div>
{/* Sidebar */}
<aside className="hidden w-64 lg:block">
<div className="sticky top-24">
<ShareButtons
url={`https://getnibi.com/${locale}/blog/${slug}`}
title={post.title}
/>
{tableOfContents.length > 0 && (
<TableOfContents headings={tableOfContents} />
)}
</div>
</aside>
</div>
</article>
)
}
Step 8: Create API Routes
View Counter API (app/api/views/[slug]/route.ts
)
typescript
import { NextRequest, NextResponse } from 'next/server'
import { incrementViews, getViews } from '@/lib/upstash'
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
const { slug } = params
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')
// Get IP for rate limiting
const forwarded = request.headers.get('x-forwarded-for')
const ip = forwarded ? forwarded.split(',')[0] : request.headers.get('x-real-ip') ?? 'unknown'
try {
if (action === 'increment') {
const views = await incrementViews(slug, ip)
return NextResponse.json({ views })
} else {
const views = await getViews(slug)
return NextResponse.json({ views })
}
} catch (error) {
console.error('View counter error:', error)
return NextResponse.json({ views: 0 })
}
}
Step 9: Create Sample Content
Create your first blog post in both languages:
French Post (content/blog/fr/guide-tps-tvq-freelance.mdx
)
mdx
---
title: "Guide complet TPS/TVQ pour freelances au Québec"
excerpt: "Tout ce que vous devez savoir sur la gestion des taxes en tant que travailleur autonome au Québec en 2024"
date: "2024-01-15"
author: "nibi-team"
category: "Taxes"
heroImage: "/blog/taxes-guide-hero.jpg"
tags: ["taxes", "tps", "tvq", "québec", "freelance"]
---
# Introduction
En tant que travailleur autonome au Québec, la gestion de la TPS et de la TVQ peut sembler complexe. Ce guide vous explique tout ce que vous devez savoir.
## Seuils d'inscription
Si vos revenus dépassent **30 000 $** sur une période de 12 mois, vous devez obligatoirement vous inscrire aux fichiers de la TPS/TVQ.
### Avantages de l'inscription volontaire
MĂŞme sous le seuil, l'inscription peut ĂŞtre avantageuse :
- Récupération des taxes sur vos dépenses d'affaires
- Crédibilité accrue auprès des clients corporatifs
- Meilleure gestion de votre trésorerie
## Calcul des taxes
Montant HT Ă— 5% = TPS Montant HT Ă— 9.975% = TVQ Total = Montant HT + TPS + TVQ
## Dates importantes Ă retenir
- **Trimestriel** : Si revenus < 1,5 M$
- **Mensuel** : Si revenus > 6 M$
- **Annuel** : Si revenus < 50 000 $
N'oubliez pas de mettre de côté environ **15%** de vos revenus pour les taxes!
English Post (content/blog/en/gst-qst-guide-freelancers.mdx
)
mdx
---
title: "Complete GST/QST Guide for Quebec Freelancers"
excerpt: "Everything you need to know about managing sales taxes as a self-employed worker in Quebec in 2024"
date: "2024-01-15"
author: "nibi-team"
category: "Taxes"
heroImage: "/blog/taxes-guide-hero.jpg"
tags: ["taxes", "gst", "qst", "quebec", "freelance"]
---
# Introduction
As a self-employed worker in Quebec, managing GST and QST can seem complex. This guide explains everything you need to know.
## Registration Thresholds
If your revenue exceeds **$30,000** over a 12-month period, you must register for GST/QST.
### Benefits of Voluntary Registration
Even below the threshold, registration can be beneficial:
- Recovery of taxes on business expenses
- Increased credibility with corporate clients
- Better cash flow management
## Tax Calculation
Amount Ă— 5% = GST Amount Ă— 9.975% = QST Total = Amount + GST + QST
## Important Dates to Remember
- **Quarterly**: If revenue < $1.5M
- **Monthly**: If revenue > $6M
- **Annual**: If revenue < $50,000
Don't forget to set aside about **15%** of your revenue for taxes!
Step 10: Style and Theme Integration
Update your tailwind.config.ts
to ensure typography plugin is configured:
typescript
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./content/**/*.{md,mdx}',
],
theme: {
extend: {
// Your existing theme extensions
typography: {
DEFAULT: {
css: {
color: '#374151',
a: {
color: '#2563eb',
'&:hover': {
color: '#1d4ed8',
},
},
'code::before': {
content: '""',
},
'code::after': {
content: '""',
},
},
},
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
}
export default config
Step 11: Add RSS Feed Generation
Create app/[locale]/blog/rss.xml/route.ts
:
typescript
import { NextRequest } from 'next/server'
import RSS from 'rss'
import { getAllPosts } from '@/lib/mdx'
export async function GET(
request: NextRequest,
{ params }: { params: { locale: string } }
) {
const { locale } = params
const posts = await getAllPosts(locale)
const siteUrl = 'https://getnibi.com'
const feed = new RSS({
title: locale === 'fr' ? 'Blog Nibi' : 'Nibi Blog',
description: locale === 'fr'
? 'Conseils et guides pour freelances au Québec'
: 'Tips and guides for Quebec freelancers',
site_url: `${siteUrl}/${locale}/blog`,
feed_url: `${siteUrl}/${locale}/blog/rss.xml`,
language: locale,
pubDate: new Date(),
})
posts.forEach((post) => {
feed.item({
title: post.title,
description: post.excerpt,
url: `${siteUrl}/${locale}/blog/${post.slug}`,
date: post.date,
categories: post.tags,
})
})
return new Response(feed.xml(), {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
},
})
}
Step 12: Testing and Deployment
Test your blog locally:
bash
# Development mode
npm run dev
# Build for production
npm run build
# Test production build
npm run start
Deploy to Vercel:
bash
# Commit your changes
git add .
git commit -m "Add bilingual MDX blog with Upstash view counting"
git push
# Deploy (automatic with Vercel)
Conclusion
You now have a fully functional, bilingual blog system that:
- âś… Supports rich MDX content with custom components
- âś… Tracks views in real-time with Upstash Redis
- âś… Generates RSS feeds for both languages
- âś… Provides excellent SEO with structured data
- âś… Matches your existing design system perfectly
- âś… Loads incredibly fast with static generation
This implementation gives you the flexibility of MDX, the performance of static generation, and the dynamic features of view counting—all while maintaining a clean, maintainable codebase that scales with your needs.
Links and Resources
- Nibi Website - See the implementation in action
- MDX Documentation - Learn more about MDX features
- Upstash Console - Manage your Redis database
- Next.js App Router Docs - Deep dive into App Router
- Tailwind Typography - Style your prose content
- Source Code Example - Full implementation reference
Troubleshooting Resources
- Next.js GitHub Issues - Common problems and solutions
- Upstash Discord - Get help from the community
- MDX Playground - Test your MDX syntax
Member discussion