Next.js Blog with Markdown: Categories, Reading Time, and RSS Feed

Introduction: Why Markdown + Next.js for a Blog?
Markdown-driven blogs remain one of the simplest, fastest and most portable ways to publish content on the web. Combining Markdown with Next.js gives you the best of two worlds: the simplicity of authoring in plain-text files and the performance, routing and SEO advantages of a modern React framework.
What you'll build across this series
- A file-based content system where each post is a Markdown file with YAML frontmatter.
- Category support surfaced on index, category pages and individual posts.
- An estimated reading-time value per post, calculated at build time.
- An RSS feed that publishes your latest posts for feed readers and subscribers.
Why choose Markdown for a blog
- Author-friendly: Write posts in any editor; Git-based workflows and editors like VS Code make collaboration simple.
- Portable: Markdown files can be moved between tools, backed up in Git, or converted to other formats later.
- Fast builds: Pure Markdown (no client-side runtime for MDX components) usually yields smaller bundles and faster page loads.
When to choose MDX instead
MDX lets you embed React components inside Markdown. Use MDX when you need interactive widgets or components in the body of posts (charts, live examples, complex embeds). Opt for plain Markdown when posts are mostly text and static content — it simplifies rendering and reduces client-side JavaScript.
Static Site Generation (SSG) benefits with Next.js
- Performance: Pages render to static HTML and can be served from CDNs with negligible latency.
- SEO: SSG provides full HTML to crawlers with metadata available for previews and social cards.
- Simplicity: No need to build a separate CMS to start publishing; Git + Markdown is enough.
Roadmap for the tutorial series (high-level)
Part 1 (this file): Introduction, project setup, content model, parsing Markdown with gray-matter, and SSG basics with reading time. Part 2: Rendering Markdown safely with remark/rehype, syntax highlighting, and images. Part 3: Categories pages, pagination and tag filters. Part 4: RSS feed generation, SEO metadata, and deployment notes.
Prerequisites
- Basic JavaScript and React familiarity
- Node.js & npm installed
- Familiarity with Git and the command line
Further Reading
- Next.js Documentation: https://nextjs.org/docs — Official docs for getStaticProps, getStaticPaths, and SSG.
- CommonMark / Markdown Guide: https://www.markdownguide.org — Markdown syntax reference.
Project Setup and Content Model
Create a new Next.js app and install a few packages we'll use throughout the tutorial. From your terminal:
npx create-next-app@latest next-markdown-blog
cd next-markdown-blog
npm install gray-matter remark remark-html reading-time date-fns
Packages used here
- gray-matter: parse YAML frontmatter from Markdown files.
- remark + remark-html: convert Markdown to HTML during build (we'll swap to remark-rehype later for safer rendering).
- reading-time: small utility to estimate minutes from a text blob.
- date-fns: lightweight date utilities for parsing/formatting.
Content folder layout
Use a content/posts directory at the project root. Each post is a .md file with YAML frontmatter. Example structure:
/content
/posts
2023-08-01-welcome.md
2024-01-01-new-year.md
Sample frontmatter schema (recommended fields)
- title: string — Post title
- date: ISO date string (YYYY-MM-DD or full ISO) — Publication date
- categories: array of strings — One or more categories
- description: string — Short excerpt for list pages and meta description
- slug: string (optional) — If not present, derived from filename
- draft: boolean (optional) — Skip building if true
Example Markdown file: content/posts/2024-01-01-new-year.md
--- title: 'Happy New Year' date: '2024-01-01' categories: - announcements - personal description: 'A short note about plans for the new year.' slug: 'new-year' ---
Hello readers — this is my first post for the new year!
Naming conventions and slugs
- You can name files like YYYY-MM-DD-title.md to keep chronological order.
- If you include a slug field, use it for the URL path; otherwise derive a slug from filename by stripping the date prefix.
Project helpers
Create a small helper folder: lib/posts.ts (or .js). This file will centralize reading and listing posts. Start by exporting constants for the content path and allowed fields.
Further Reading
- Next.js Getting Started: https://nextjs.org/learn/basics/getting-started — Quickstart for new Next.js apps.
- gray-matter: https://github.com/mdx-js/gray-matter — Library to parse frontmatter from Markdown files.
Parsing Markdown and Frontmatter with gray-matter
The next step is reading Markdown files from disk and parsing their frontmatter and content. We'll use Node's fs and path modules together with gray-matter.
A minimal utility to read files and parse frontmatter (TypeScript-flavored pseudocode):
import fs from 'fs' import path from 'path' import matter from 'gray-matter' import { parseISO, isFuture } from 'date-fns'const postsDir = path.join(process.cwd(), 'content', 'posts')
export function getPostFiles() { return fs.readdirSync(postsDir).filter(f => f.endsWith('.md')) }
export function readPostFile(filename) { const fullPath = path.join(postsDir, filename) const raw = fs.readFileSync(fullPath, 'utf8') const { data, content } = matter(raw)
return { data, content, filename } }
Normalize metadata and create safe slugs
When you parse frontmatter, normalize the values you need downstream:
- Ensure date is a valid Date object (or ISO string) and ignore posts with future dates or draft: true unless you want them published locally.
- Normalize categories to an array of lowercase strings.
- Derive slug by looking for a slug field, otherwise strip prefix from filename: 2024-01-01-title.md -> title
Example normalization function:
function normalizePost(raw) { const { data, content, filename } = raw const slugFromFile = filename.replace(/^(\\d{4}-\\d{2}-\\d{2}-)?(.*)\\.md$/, '$2') const slug = (data.slug || slugFromFile).toString() const date = data.date ? parseISO(data.date) : null const isDraft = !!data.draft const categories = (data.categories || []).map(c => c.toLowerCase())
return { ...data, date, slug, content, categories, isDraft } }
Handling drafts and future dates
It's common to skip draft posts or posts dated for the future in production builds. During development you can include them locally.
Validating required fields
At build time, ensure required fields (title, date, description) exist. Fail the build with a helpful error if not — this avoids broken pages and missing metadata in RSS/SEO.
Further Reading
- gray-matter README: https://github.com/jonschlinkert/gray-matter — Examples and API for parsing frontmatter.
- moment.js Alternatives: https://date-fns.org — Use date-fns for lightweight date parsing/formatting.
Static Site Generation: getStaticProps and getStaticPaths
With parsed posts and normalized metadata, wire SSG to create the blog index and individual post pages.
Generating paths for each post
In pages/posts/[slug].tsx (or .js) implement getStaticPaths using the slugs you computed in your posts helper:
export async function getStaticPaths() { const posts = getAllPosts() // returns normalized posts const paths = posts .filter(p => !p.isDraft && !isFuture(p.date)) .map(p => ({ params: { slug: p.slug } }))
return { paths, fallback: false } }
Fetching post content for each page
In getStaticProps, locate the post by slug and convert Markdown to HTML using remark. Add reading-time metadata either using the reading-time package or a small custom function.
import readingTime from 'reading-time' import { remark } from 'remark' import remarkHtml from 'remark-html'export async function getStaticProps({ params }) { const post = getPostBySlug(params.slug) const md = await remark().use(remarkHtml).process(post.content) const contentHtml = md.toString() const rt = readingTime(post.content)
return { props: { post: { ...post, contentHtml, readingTime: rt } } } }
Custom reading-time (simple)
If you want a zero-dependency approach, a typical method counts words and divides by an average words-per-minute (commonly 200–250 wpm):
function estimateReadingTime(text, wordsPerMinute = 200) {
const words = text.trim().split(/\\s+/).length
const minutes = Math.max(1, Math.round(words / wordsPerMinute))
return { minutes, words }
}
Where to render reading time
Display it in the post header next to the date and categories. Also include readingTime.minutes (or minutes) in list pages so readers can scan posts quickly.
Incremental static regeneration (ISR)
If you plan to add posts without redeploying, use revalidate in getStaticProps to enable ISR. For a mostly static blog that updates on deploy, you can omit revalidate and rely on normal builds.
Edge cases and performance
- Avoid loading full post content when building the index. For lists, read only frontmatter and produce excerpts to keep build times low.
- If you have many posts (thousands), consider a caching strategy or incremental builds to limit build-time IO.
Further Reading
- Next.js Data Fetching: https://nextjs.org/docs/basic-features/data-fetching — Reference for getStaticProps and getStaticPaths.
- reading-time npm: https://www.npmjs.com/package/reading-time — A small library that computes reading time from text.
Rendering Markdown with remark and rehype
Next.js blogs that source posts from Markdown usually convert that Markdown into HTML (or React elements) during build time. The unified ecosystem (remark -> rehype) is the standard approach: use remark to parse/transform Markdown, then rehype to transform/sanitize HTML and (optionally) produce React elements.
Here are two common server-side approaches:
- Use unified (remark + rehype) to produce sanitized HTML strings that you dangerouslySetInnerHTML in a React component; or
- Use next-mdx-remote or rehype-react to compile Markdown into React elements so you can render components inline.
Minimal unified pipeline (produce HTML string):
import { unified } from 'unified' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import rehypeStringify from 'rehype-stringify' import rehypeSanitize from 'rehype-sanitize'export async function markdownToHtml(markdown) { const result = await unified() .use(remarkParse) .use(remarkRehype) .use(rehypeSanitize) // important .use(rehypeStringify) .process(markdown)
return String(result) }
If you want React nodes instead of raw HTML, either use rehype-react or use next-mdx-remote (recommended for Next.js because of good SSR support):
- rehype-react converts an HAST (HTML AST) into React elements (you can map tags to components).
- next-mdx-remote compiles MDX/Markdown to a serialized form that you hydrate with React on the client — good for MDX features and server-side rendering.
Tip on choosing: if you only need to render Markdown and want simple HTML with fast builds, produce HTML strings and render with a simple React wrapper. If you need interactive embeds or React components inside posts, adopt MDX/next-mdx-remote.
Syntax highlighting
For code block highlighting you have a few popular options:
- rehype-highlight (uses highlight.js) — simple plugin chain:
import rehypeHighlight from 'rehype-highlight'
// .use(rehypeHighlight) before rehypeStringify
- Shiki (more modern, themeable, high-quality) — use remark-shiki or a shiki-based rehype plugin. Shiki works at build time and outputs highlighted HTML or inline tokens.
Example with rehype-highlight:
unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeHighlight) // add language classes + markup
.use(rehypeSanitize)
.use(rehypeStringify)
Images, external links, and asset handling
-
Images: prefer using Next.js'
next/imagefor performance (optimized sizes, lazy loading). To integrate, parse images in Markdown and replacetags with a React Image component when you produce React nodes (rehype-react or next-mdx-remote component mapping). If you output raw HTML, consider post-processing image tags to add loading attributes and wrapper classes, or keep them and rely on normal
behavior.
-
External links: use a rehype plugin such as rehype-external-links (or write a small rehype plugin) to add
rel="noopener noreferrer"andtarget="_blank"to external anchors.
Sanitization and security
- Always sanitize HTML produced from Markdown unless you fully control all posts. rehype-sanitize is commonly used; it accepts a schema you can extend to allow specific tags/attributes.
- When using MDX and allowing components, strictly control which components you pass into the MDX scope. Avoid passing a global set of components that expose privileged APIs.
Embedding components safely
If you adopt MDX or next-mdx-remote, embed components by providing a components map during render. For example with next-mdx-remote:
// In the page component import { MDXRemote } from 'next-mdx-remote' import MyImage from '@/components/MyImage'const components = { img: MyImage }
<MDXRemote {...source} components={components} />
Further Reading
Adding Categories and Category Pages
Categories are best stored in post frontmatter (e.g., a YAML array under categories:). The steps are:
- Normalize and aggregate categories across all posts during build.
- Create a slug for each category (URL-friendly) and generate static pages at /categories/[slug].
- On the main blog index, provide a list of categories with counts and allow filtering.
Extract and normalize categories
When you parse frontmatter for each post, normalize categories to a consistent shape — trim whitespace, collapse case (e.g., to lowercase for de-dup), and keep the original display name if you care about capitalization.
Example normalization:
import slugify from 'slugify'
function normalizeCategory(name) { const trimmed = String(name).trim() return { name: trimmed, slug: slugify(trimmed, { lower: true, strict: true }) } }
Aggregate categories and counts
While you collect posts (for example in getStaticProps or a pre-build script), build a map:
const categories = new Map() posts.forEach(post => { (post.categories || []).forEach(cat => { const { name, slug } = normalizeCategory(cat) const entry = categories.get(slug) || { name, slug, count: 0, posts: [] } entry.count++ entry.posts.push(post) categories.set(slug, entry) }) })
const categoryArray = Array.from(categories.values())
Generate static category pages
Use getStaticPaths to create a path for each category slug and getStaticProps to feed the list of posts for that category to the page:
export async function getStaticPaths() { const categorySlugs = await getAllCategorySlugs() // implement return { paths: categorySlugs.map(slug => ({ params: { category: slug } })), fallback: false } }
export async function getStaticProps({ params }) { const posts = await getPostsByCategory(params.category) return { props: { posts, category: params.category } } }
UI & UX considerations
- Show counts next to each category (e.g., Tech (12)).
- Provide a canonical URL for each category page using the canonical link rel to avoid duplicate content issues.
- Consider a flat category namespace vs nested tags. If you plan many categories, allow search or paginate category pages.
Further Reading
- slugify — Helpful for converting category names to URL-friendly slugs.
- SEO Best Practices — Guidance on category pages and crawlability.
Generate an RSS Feed for Syndication
RSS is XML: each
Two main approaches:
- Use the feed npm package to generate RSS/Atom programmatically.
- Hand-roll a simple XML builder (string templates) and write to public/feed.xml during build.
Using the feed package (concise example)
import { Feed } from 'feed' import fs from 'fs'export async function generateFeed(siteMeta, posts) { const feed = new Feed({ title: siteMeta.title, description: siteMeta.description, id: siteMeta.url, link: siteMeta.url, language: 'en' })
posts.forEach(post => { feed.addItem({ title: post.title, id:
${siteMeta.url}/posts/${post.slug}, link:${siteMeta.url}/posts/${post.slug}, description: post.excerpt || post.description, date: new Date(post.date), category: (post.categories || []).map(c => ({ name: c })) }) })
// Write both RSS and Atom to public fs.writeFileSync('public/feed.xml', feed.rss2()) fs.writeFileSync('public/atom.xml', feed.atom1()) }
When to run
- Call generateFeed during your build (e.g., from getStaticProps at the root index page or a dedicated build script). Writing to /public ensures Next.js serves it as a static file.
Including reading time and categories
- You can include reading time in
or a custom element within the item. Remember to follow RSS rules: add custom namespaces if you create nonstandard elements. - Categories can be included with
elements using the feed package or manual XML.
Validation and testing
- Validate your feed with an online validator (W3C or dedicated RSS validators) and spot-check in an aggregator (Feedly, Inoreader) to ensure items render correctly.
Further Reading
- RSS 2.0 Specification — Official specification for RSS elements.
- feed npm — Utility to build RSS/Atom feeds programmatically.
Advanced: Reading Time Algorithms and MDX Considerations
Reading time basics
A simple reading-time algorithm counts words and divides by a words-per-minute (WPM) baseline (commonly 200–275 WPM). The npm reading-time package uses a 200 WPM baseline and includes nuance such as rounding and adding a minimum of one minute.
Custom adjustments
- Exclude code blocks and captions: code and markup normally read more slowly or are skipped. When you compute reading time from the Markdown AST, ignore
or fenced code nodes. - Images: treat images as contributing a small fixed time (for example, 12–15 seconds) instead of words.
- Minimum and rounding: round to the nearest minute or always show "< 1 min" for very short reads.
Simple custom implementation:
function readingTimeFromText(markdown) {
const text = stripMarkdown(markdown) // remove code fences, images, links
const words = text.trim().split(/\s+/).length
const wpm = 220
const minutes = Math.max(1, Math.round(words / wpm))
return { words, minutes }
}
MDX vs Markdown
- Markdown + remark: excellent for static content and fast builds, minimal runtime. You get a stable pipeline for parsing, transforming, sanitizing, and producing HTML.
- MDX: allows embedding React components directly inside your posts. It increases flexibility (interactive embeds) but also increases bundle size and complexity (component hydration, security considerations).
When to choose MDX
- Use MDX if you plan to include interactive components (charts, embeds) inside many posts.
- Stick with Markdown if most content is static text with images and code blocks — you’ll benefit from simpler render paths and smaller client bundles.