Skip to content

Building a Blog From Markdown in Gatsby — Complete Guide

DodaTech Updated 2026-06-28 5 min read

Learn how to build a complete blog in Gatsby using Markdown files, create pages, add tags, and implement pagination for a production-ready blog.

In this lesson, you'll combine source plugins, transformer plugins, and programmatic page creation to build a full blog from Markdown.

What You'll Learn

How to structure Markdown files, configure transformers, create post pages programmatically, add tag archives, and implement pagination.

Why It Matters

A Markdown blog is the most common Gatsby use case. Understanding this workflow gives you a foundation for any content-driven Gatsby site.

flowchart TD
    A[Markdown Files] --> B[Source Plugin]
    B --> C[Transformer Plugin]
    C --> D[gatsby-node.js]
    D --> E[Post Pages]
    D --> F[Tag Pages]
    D --> G[Pagination]
    style A fill:#639,color:#fff
    style D fill:#4a148c,color:#fff

Markdown File Structure

---
title: "Getting Started with Gatsby"
date: 2026-06-28
tags: ["gatsby", "react", "tutorial"]
featured: true
author: "DodaTech"
image: ./hero.jpg
---

Welcome to this tutorial on Gatsby. In this post, we'll explore...

## What is Gatsby?

Gatsby is a React-based static site generator...

### Why Static Sites?

Static sites are fast, secure, and scalable...

Organize files:

content/blog/
├── getting-started-gatsby/index.md
├── gatsby-vs-nextjs/index.md
├── gatsby-plugins-guide/index.md
└── deploying-gatsby/index.md

Each post in its own directory with an index.md file and associated assets.

Slug Creation in gatsby-Node.js

Generate slugs from file paths:

// gatsby-node.js
const path = require('path');
const { createFilePath } = require('gatsby-source-filesystem');

exports.onCreateNode = ({ node, getNode, actions }) => {
  const { createNodeField } = actions;

  if (node.internal.type === 'MarkdownRemark') {
    const slug = createFilePath({ node, getNode, basePath: 'content/blog' });
    createNodeField({ node, name: 'slug', value: slug });
  }
};

Output: A file at content/blog/getting-started-gatsby/index.md gets a slug /getting-started-gatsby/.

Creating Post Pages

// gatsby-node.js
exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions;

  const result = await graphql(`
    query {
      allMarkdownRemark(sort: { frontmatter: { date: DESC } }) {
        nodes {
          fields { slug }
          frontmatter { tags }
        }
      }
    }
  `);

  const posts = result.data.allMarkdownRemark.nodes;

  // Create individual post pages
  posts.forEach((post, index) => {
    const prev = index < posts.length - 1 ? posts[index + 1] : null;
    const next = index > 0 ? posts[index - 1] : null;

    createPage({
      path: post.fields.slug,
      component: path.resolve('./src/templates/blog-post.js'),
      context: {
        slug: post.fields.slug,
        prevSlug: prev?.fields.slug || null,
        nextSlug: next?.fields.slug || null
      }
    });
  });
};

Output: Each Markdown file becomes a blog post with prev/next navigation passed through context.

Post Template with Navigation

// src/templates/blog-post.js
import { graphql, Link } from 'gatsby';
import React from 'react';

export default function BlogPost({ data, pageContext }) {
  const post = data.markdownRemark;

  return (
    <article>
      <h1>{post.frontmatter.title}</h1>
      <p>{post.frontmatter.date} &middot; {post.timeToRead} min read</p>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />

      <nav>
        {pageContext.prevSlug && (
          <Link to={pageContext.prevSlug}>&larr; Previous Post</Link>
        )}
        {pageContext.nextSlug && (
          <Link to={pageContext.nextSlug}>Next Post &rarr;</Link>
        )}
      </nav>
    </article>
  );
}

export const query = graphql`
  query BlogPostBySlug($slug: String!) {
    markdownRemark(fields: { slug: { eq: $slug } }) {
      html
      timeToRead
      frontmatter { title date(formatString: "MMMM D, YYYY") }
    }
  }
`;

Output: Each post displays with navigation to adjacent posts.

Tag Pages

Create archive pages for each tag:

// gatsby-node.js (add to createPages)
const tags = new Set();
posts.forEach(post => {
  post.frontmatter.tags?.forEach(tag => tags.add(tag));
});

tags.forEach(tag => {
  createPage({
    path: `/tags/${tag}/`,
    component: path.resolve('./src/templates/tag-page.js'),
    context: { tag }
  });
});
// src/templates/tag-page.js
export const query = graphql`
  query TagPage($tag: String!) {
    allMarkdownRemark(
      filter: { frontmatter: { tags: { in: [$tag] } } }
      sort: { frontmatter: { date: DESC } }
    ) {
      nodes {
        frontmatter { title date }
        fields { slug }
        excerpt
      }
    }
  }
`;

Output: Each tag gets a page at /tags/gatsby/ listing all posts with that tag.

Pagination

Create paginated archive pages:

exports.createPages = async ({ graphql, actions }) => {
  const postsPerPage = 6;
  const result = await graphql(`query { allMarkdownRemark { totalCount } }`);
  const totalPages = Math.ceil(result.data.allMarkdownRemark.totalCount / postsPerPage);

  Array.from({ length: totalPages }).forEach((_, i) => {
    createPage({
      path: i === 0 ? '/blog' : `/blog/${i + 1}`,
      component: path.resolve('./src/templates/blog-list.js'),
      context: { limit: postsPerPage, skip: i * postsPerPage, currentPage: i + 1, totalPages }
    });
  });
};

Common Mistakes

  1. Not creating slugs with createFilePath: The helper generates clean slugs from file paths. Manual slug creation is error-prone.
  2. Forgetting to sort posts: Without sort: { frontmatter: { date: DESC } }, posts appear in arbitrary order.
  3. Not passing prev/next navigation context: Navigation between posts requires passing adjacent post slugs through createPage context.
  4. Using dangerouslySetInnerHTML without sanitization: Markdown from trusted sources is safe. For user-generated content, sanitize first.
  5. Missing gatsby-remark-images for inline images: Images in Markdown aren't optimized without this plugin.

Practice Questions

  1. How do you generate slugs from file paths? Answer: Use the createFilePath helper from gatsby-source-filesystem in the onCreateNode API.

  2. How do you create tag archive pages? Answer: Collect all unique tags from posts, then call createPage for each tag with a template that filters posts by that tag.

  3. What is the purpose of prev/next navigation context? Answer: It passes the slug of the previous and next post to the template, enabling navigation between adjacent posts.

  4. How do you paginate the blog listing? Answer: Calculate total pages from post count, loop with Array.from, and pass skip and limit to the page query.

Challenge

Extend the blog with: image galleries in posts (using gatsby-remark-images), related posts based on shared tags, an RSS feed with gatsby-plugin-feed, and reading progress indicator.

Mini Project

Build a complete blog with: 20+ Markdown posts, 5+ tag categories, paginated archive (6 per page), prev/next navigation, tag pages, featured posts section, and an RSS feed.

FAQ

How do I add image captions in Markdown?

: Use gatsby-remark-image-attributes or write custom HTML in Markdown with <figure> and <figcaption>.

Can I use MDX instead of Markdown?

: Yes. Install gatsby-plugin-mdx as a replacement for gatsby-transformer-remark to embed JSX in your content.

How do I handle draft posts?

: Add a draft: true frontmatter field. In createPages, filter out drafts: filter: { frontmatter: { draft: { ne: true } } }.

How do I add a search feature?

: Use gatsby-plugin-local-search with flexsearch or lunr for client-side search without external services.

What's Next

Learn about Gatsby File System Route API for a simpler approach to creating pages without manual gatsby-node.js configuration.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro