Skip to main content
Lead Generation Websites, Google Maps Ranking, WhatsApp Funnels, Ecommerce, SEO, Web DesignSpeed Optimization · Conversion Optimization · Monthly Lead Systems · AI AutomationLead Generation Websites, Google Maps Ranking, WhatsApp Funnels, Ecommerce, SEO, Web Design

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

Published: January 1, 2026
Written by Sumeet Shroff
Next.js Blog with Markdown: Categories, Reading Time, and RSS Feed
Table of Contents
  1. Introduction: Why Markdown + Next.js for a Blog?
  2. Project Setup and Content Model
  3. Parsing Markdown and Frontmatter with gray-matter
  4. Static Site Generation: getStaticProps and getStaticPaths
  5. Rendering Markdown with remark and rehype
  6. Adding Categories and Category Pages
  7. Generate an RSS Feed for Syndication
  8. Advanced: Reading Time Algorithms and MDX Considerations
  9. SEO: Meta Tags, Open Graph, and Sitemap
  10. Case Study: Sample Project Structure and Deployment
  11. Conclusion, Further Reading, and Next Steps
  12. About Prateeksha Web Design

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.

FactPlain Markdown often produces smaller client bundles and faster initial loads than MDX because the Markdown rendering can happen entirely at build time.

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
TipPlan your frontmatter before you write many posts. A consistent schema (title, date, categories, description, slug) reduces the need for migrations later.

Further Reading

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.
TipStore only canonical metadata and content in Markdown files. Avoid including derived fields (like readingTime) in frontmatter — compute those during build so they stay accurate.

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

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.

WarningDo not publish posts with future dates unless you intend them to go live. SSG will include only what you return from getStaticProps — but it's easy to accidentally push drafts if your build script includes them.

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

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.

TipSet fallback to 'blocking' if you expect new slugs after deployment and want Next.js to render them on-demand the first time they're requested.

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

TipDuring development, run builds occasionally to ensure your normalization and validation code catches schema problems early — dev servers can be forgiving compared to production builds.

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:

  1. Use unified (remark + rehype) to produce sanitized HTML strings that you dangerouslySetInnerHTML in a React component; or
  2. 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.

TipUse rehype-sanitize (or a strict sanitization schema) whenever you accept Markdown from untrusted sources — it prevents XSS by stripping dangerous nodes/attributes.

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)
Factrehype-highlight injects language-specific class names (e.g., language-js) so you can pair with CSS themes or Prism-style stylesheets.

Images, external links, and asset handling

  • Images: prefer using Next.js' next/image for performance (optimized sizes, lazy loading). To integrate, parse images in Markdown and replace tags 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" and target="_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.
WarningDo not render unsanitized HTML from untrusted Markdown with dangerouslySetInnerHTML — that’s a common XSS vector.

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} />

TipIf you need just a few dynamic widgets (e.g., Tweets, YouTube embeds), create explicit shortcodes/components and whitelist them in MDX rather than allowing arbitrary component imports.

Further Reading

  • remarkjs — Plugins ecosystem for processing markdown.
  • rehype — HTML processing tools and plugins.

Adding Categories and Category Pages

Categories are best stored in post frontmatter (e.g., a YAML array under categories:). The steps are:

  1. Normalize and aggregate categories across all posts during build.
  2. Create a slug for each category (URL-friendly) and generate static pages at /categories/[slug].
  3. 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.
TipUse slugify with the `strict` option so category URLs don't contain spaces or punctuation that can complicate routing.
FactCategory pages are indexable by search engines and can increase discoverability — add clear H1/H2 and meta description per category.
WarningAvoid creating categories with near-duplicate names (e.g., "React" vs "react") — normalize early to prevent fragmented archives.

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 should include title, link, guid, pubDate, and description/content. You can also include category elements and custom fields (e.g., reading time via itunes:duration for podcasts — for blogs just include a custom namespace or add reading time inside description).

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.
TipWrite feed.xml during build and commit only the generation step — don’t check generated files into source control unless you want them versioned.
FactMany feed readers expect RFC-822-style pubDate formats — using JavaScript Date objects with feed packages handles this formatting for you.
WarningIf your site URL or post paths are environment-dependent (staging vs production), ensure you generate feed.xml with the production base URL before publishing.

Further Reading

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.
TipIf you only need a few interactive widgets, consider using shortcodes (replace tokens during build) or a small set of allowed MDX components rather than converting all posts to MDX.
FactMDX enables component mapping so you can replace default HTML tags or add custom components, but each mapped component can increase client-side bundle size if it requires hydration.
WarningWhen using MDX, never import untrusted components or allow post authors to supply arbitrary component imports — this is a direct remote code execution risk.

Further Reading


SEO: Meta Tags, Open Graph, and Sitemap

A strong SEO setup gives each post and category page its own, crawlable identity. We'll build a small reusable Meta component that reads frontmatter (title, description, date, canonical, image, author, categories) and emits standard meta tags, Open Graph, and Twitter Card markup. Then we'll generate a sitemap.xml at build time so search engines discover every post and category route.

Key ideas:

  • Use a single Meta/Head component for pages to ensure consistent markup.
  • Pull canonical URLs from a SITE_URL env var to avoid accidental relative links.
  • Generate sitemap.xml during build using your posts' slugs and category routes.

Example reusable Meta component (components/Meta.tsx):

import Head from 'next/head'
import React from 'react'

interface MetaProps { title: string description?: string canonical?: string image?: string date?: string author?: string type?: 'article' | 'website' }

export default function Meta({ title, description = '', canonical, image, date, author, type = 'article' }: MetaProps) { const siteTitle = ${title} return ( <Head> <title>{siteTitle}</title> <meta name="description" content={description} /> {canonical && <link rel="canonical" href={canonical} />}

  {/* Open Graph */}
  &lt;meta property=&quot;og:type&quot; content={type} /&gt;
  &lt;meta property=&quot;og:title&quot; content={siteTitle} /&gt;
  &lt;meta property=&quot;og:description&quot; content={description} /&gt;
  {image &amp;&amp; &lt;meta property=&quot;og:image&quot; content={image} /&gt;}
  {canonical &amp;&amp; &lt;meta property=&quot;og:url&quot; content={canonical} /&gt;}

  {/* Twitter */}
  &lt;meta name=&quot;twitter:card&quot; content={image ? 'summary_large_image' : 'summary'} /&gt;
  &lt;meta name=&quot;twitter:title&quot; content={siteTitle} /&gt;
  &lt;meta name=&quot;twitter:description&quot; content={description} /&gt;
  {image &amp;&amp; &lt;meta name=&quot;twitter:image&quot; content={image} /&gt;}
  {author &amp;&amp; &lt;meta name=&quot;twitter:creator&quot; content={`@${author}`} /&gt;}

  {/* Article metadata (optional) */}
  {date &amp;&amp; &lt;meta property=&quot;article:published_time&quot; content={date} /&gt;}
&lt;/Head&gt;

) }

Usage in a post page (pages/posts/[slug].tsx):

// after parsing frontmatter
<Meta
  title={frontmatter.title}
  description={frontmatter.description || excerpt}
  canonical={`${process.env.NEXT_PUBLIC_SITE_URL}/posts/${slug}`}
  image={frontmatter.image ? `${process.env.NEXT_PUBLIC_SITE_URL}${frontmatter.image}` : undefined}
  date={frontmatter.date}
  author={frontmatter.author}
/>
Tip Always set NEXT_PUBLIC_SITE_URL (e.g. https://example.com). It makes canonical, og:url, and sitemap generation deterministic across environments.
Fact Twitter Cards prefer summary_large_image if you provide an image — it increases clickthroughs on many platforms.
Warning Don’t emit duplicate title/description markup across category pages and posts; keep category descriptions unique to avoid thin-content penalties.

Generating sitemap.xml during build

A simple Node script can read your posts (or import your posts index utility), assemble URLs (posts + category pages), and write public/sitemap.xml. Example script: scripts/generate-sitemap.js

const fs = require('fs')
const path = require('path')
const glob = require('glob')
const matter = require('gray-matter')

const SITE_URL = process.env.SITE_URL || 'https://example.com' const postsDir = path.join(process.cwd(), 'posts')

const files = glob.sync('**/*.md', { cwd: postsDir })

const urls = files.map(file => { const source = fs.readFileSync(path.join(postsDir, file), 'utf8') const { data } = matter(source) const slug = file.replace(/.mdx?$/, '').replace(/index$/, '') const loc = ${SITE_URL}/posts/${slug}.replace(//+/g, '/') return { loc, lastmod: data.date || fs.statSync(path.join(postsDir, file)).mtime.toISOString() } })

// Add category pages if you have a categories list const categories = new Set() files.forEach(file => { const source = fs.readFileSync(path.join(postsDir, file), 'utf8') const { data } = matter(source) ;(data.categories || []).forEach(c => categories.add(c)) }) ;[...categories].forEach(cat => { urls.push({ loc: ${SITE_URL}/category/${encodeURIComponent(cat)}, lastmod: new Date().toISOString() }) })

const xml = &lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;\n&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;\n${urls .map(u =&gt; <url>\n <loc>${u.loc}</loc>\n <lastmod>${u.lastmod}</lastmod>\n </url>) .join('\n')}\n&lt;/urlset&gt;

fs.writeFileSync(path.join(process.cwd(), 'public', 'sitemap.xml'), xml) console.log('sitemap.xml written')

Add to package.json so sitemap is produced after build:

{
  "scripts": {
    "build": "next build",
    "postbuild": "node scripts/generate-sitemap.js"
  }
}

SEO for category pages

  • Give category pages unique, descriptive meta descriptions (e.g., "Articles about JavaScript patterns and tips").
  • Use canonical links pointing to the category URL.
  • If you paginate categories, include rel="next"/"prev" as appropriate and canonicalize to the first page or a dedicated canonical URL.

Further Reading


Case Study: Sample Project Structure and Deployment

Below is a representative repository layout that includes the pieces you built across the tutorial. This layout keeps content, build scripts, and reusable components clear.

Example project tree (abridged):

my-blog/
├─ posts/                  # Markdown posts
│  ├─ 2024-01-01-welcome.md
│  └─ ...
├─ public/
│  ├─ images/
│  └─ sitemap.xml          # generated at build
├─ components/
│  ├─ Meta.tsx
│  ├─ Layout.tsx
│  └─ PostPreview.tsx
├─ lib/
│  ├─ posts.ts             # helpers: getAllPosts, getPostBySlug, categories
│  └─ reading-time.ts
├─ pages/
│  ├─ index.tsx
│  ├─ posts/[slug].tsx
│  ├─ category/[slug].tsx
│  └─ rss.xml.tsx          # optional serverless RSS XML route
├─ scripts/
│  ├─ generate-sitemap.js
│  └─ generate-rss.js
├─ next.config.js
├─ package.json
└─ README.md

Key files to check before deployment:

  • components/Meta.tsx — consistent meta tags for posts and categories
  • lib/posts.ts — single source of truth for listing posts and categories
  • scripts/generate-sitemap.js and scripts/generate-rss.js — run at build to produce public files
  • pages/rss.xml.tsx or public/rss.xml — RSS reachable at /rss.xml
  • next.config.js — ensure images.domains and basePath (if used) match SITE_URL

Deployment tips

  1. Vercel (recommended for Next.js):

    • Connect your Git repo and set the framework to Next.js.
    • Set Environment Variables: NEXT_PUBLIC_SITE_URL (e.g. https://yourdomain.com), SITE_URL for scripts if needed.
    • Build command: npm run build (postbuild will run the sitemap script and produce public/sitemap.xml).
  2. Netlify (static export or serverless):

    • If using static export (next export) be sure to test that all dynamic pages are pre-rendered.
    • Alternatively use adapters like next-on-netlify or Vercel build.

Caching, invalidation, and incremental updates

  • If you use purely static builds: any new post requires a rebuild (trigger via Git push or CI).
  • Consider Incremental Static Regeneration (ISR) with revalidate in getStaticProps for faster updates without full rebuilds — but you must host where ISR is supported (Vercel).
  • For caches (CDN), ensure you invalidate or use cache headers appropriately. Vercel handles most cache invalidation on deploys; if you serve via a CDN in front of S3, you may need a cache purge step.
Tip Automate build triggers from content-authoring workflows (e.g., GitHub Actions on content PR merge) to rebuild and regenerate sitemap/rss automatically.
Fact Most static-hosting providers (Vercel, Netlify) will serve public/sitemap.xml and public/rss.xml without extra config.
Warning If you use a SITE_URL mismatch between environment and sitemap generation, search engines may index the wrong host — be consistent across build environments.

Deployment checklist (quick):

  • NEXT_PUBLIC_SITE_URL set in production env
  • sitemap.xml generated and reachable at /sitemap.xml
  • rss.xml available at /rss.xml
  • robots.txt in public (allow/disallow rules)
  • Analytics (e.g., Google Analytics or Plausible) added to Layout
  • Redirects configured (Netlify/_redirects or vercel.json) for legacy URLs

Further Reading


Conclusion, Further Reading, and Next Steps

You've built a static Next.js blog powered by Markdown with categories, reading time, RSS, and SEO-ready metadata. This final section summarizes the completed features, recommended next enhancements, and a short maintenance checklist.

What you now have:

  • Markdown posts parsed with frontmatter (title, date, categories, description, image).
  • Category pages that aggregate posts by tag/category.
  • Reading time calculated and displayed alongside posts.
  • RSS feed generation and sitemap.xml produced at build time.
  • Reusable Meta component emitting title, description, canonical, Open Graph, and Twitter Card tags.

Sensible next features to add

  • Pagination: load a limited number of posts per page for index and category pages. Add rel="next"/"prev" and canonicalization for SEO.
  • Multi-author support: add an authors/ directory or authors in frontmatter; create author pages with social links and bios.
  • Comments: add lightweight systems like Staticman, Utterances (GitHub issues), or third-party providers (Disqus, Commento).
  • Full-text search: integrate Algolia, MeiliSearch, or client-side search via lunr.js or FlexSearch for small sites.
  • MDX migration: move to MDX when you want React components inside posts (e.g., interactive examples).
  • Remark and Rehype plugins: add syntax highlighting, auto-linked headings, footnotes, and image optimization. Example plugins: remark-prism, rehype-slug, remark-autolink-headings.

Maintenance and automation checklist

  • Automate builds whenever content changes (Git pushes or a CMS webhook).
  • Ensure script-based generation for sitemap and RSS is part of CI/CD (postbuild or a CI step).
  • Periodically verify canonical URLs and og:image accessibility (images must be public and accessible to social crawlers).
  • Monitor performance and keep bundle size small; avoid heavy client-side libraries where possible.

Resources to deepen your knowledge

Tip Start with a small set of remark plugins (e.g., rehype-prism + rehype-slug) and add only what you need — each plugin adds processing time.
Fact RSS and sitemap are low-effort high-value: they help subscribers and search engines discover new content reliably.
Warning When migrating to MDX, audit existing frontmatter and components — MDX can introduce runtime dependencies and a larger bundle.

Final checklist before shipping to production

  • All pages have Meta component with correct metadata
  • sitemap.xml and rss.xml are generated and valid (use online validators)
  • Environment variables (NEXT_PUBLIC_SITE_URL) set in production
  • Build and deploy tested end-to-end (links, images, social previews)
  • Monitoring/analytics configured and privacy considerations reviewed

Further Reading

About Prateeksha Web Design

Prateeksha Web Design helps businesses turn tutorials like "Next.js Blog with Markdown: Categories, Reading Time, and RSS Feed" into real-world results with custom websites, performance optimization, and automation. From strategy to implementation, our team supports you at every stage of your digital journey.

Chat with us now Contact us today.

Sumeet Shroff
Sumeet Shroff
Sumeet Shroff is a renowned expert in web design and development, sharing insights on modern web technologies, design trends, and digital marketing.

Comments

Leave a Comment

Loading comments...