Static Blog
2025-10-02
A step-by-step guide to building a static blog with Next.js, using dynamic routes, gray-matter, and react-markdown. The perfect first project for learning Next.js.
In this post, I will walk you through the process of building a static blog using Next.js' dynamic routes, gray-matter
, and react-markdown
packages.
By the end of this project you will have gained:
- Hands-on experience using Next.js' Link component, Image component, and dynamic routes.
- Hands-on experience using
gray-matter
andreact-markdown
packages. - Hands-on experience using git for version control.
- A personal blog website you can iterate over to incrementally improve upon, all at no cost. How great is that!!
Prerequisites:
- Basic knowledge of HTML, CSS, and JavaScript
- Familiarity with React.js
- Basic understanding of Next.js
- Next.js installed
Next.js has amazing documentation and tutorials that is worth spending some time on.
If you don't already have a Next.js development environment set up, you can follow my Starting a Next.js Project post to get started.
Ready!? Let's do this!
Reminder:
Before we start developing don't forget to take full advantage of what Git
has to offer!
# create a new branch git checkout -b feature/blog
And, since we already know which packages we will be using to develop this feature...
npm install gray-matter react-markdown
And, at the end of any major changes that you're happy with to commit the changes
git add . git commit -m "installed react-markdown and gray-matter"
The big picture
I find it helpful to begin with the end in mind. So let's talk a bit about what we're trying to achieve here.
For our simple static blog, I'd like to be able to write my blog posts in an markdown file, for example my-first-post.md
, and place it in a designated folder, say src/content/posts
, and the system automatically publishes the posts.
If we break down this task into smaller individual tasks we get:
- create a folder where we will store our markdown files
- develop a helper function to curate a list of all post within this folder
- develop a helper function to pick individual posts
- create a page that shows a list of all available post
- create a page to display individual post
- take the list of posts and generate a static web page for each post
- create a route to point to each individual post
This list of tasks looks daunting, but Next.js has many builtin features that does the heavy lifting on many web development tasks which makes things much easier for developers.
As previously mentioned, we will leverage Next.js' static site generation (SSG) feature and a couple of packages to make this process simple and straight forward.
Let's start crossing off the tasks off our list, shall we? To get a quick win, lets start with the easiest one; creating directories.
Directory Structure
First on the list is a place to hold our markdown files to be rendered as a blog post
# the -p flag create intermediate directories as needed mkdir -p content/posts
We can safely cross off task #1. /cheer
While we're at it, let's create a directory to hold our helper functions too!
mkdir lib
There's nothing like a tidy workspace!
project ├── src │ ├── app │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── content │ │ └── posts │ └── lib ...
Don't forget to commit changes as we go
Helper Functions
Here we know exactly what we need. We need a way to retrieve a list of all posts and a way to retrieve specific post. And, the posts needs to be the form of an object to be used as input to Next.js (well, technically React.js) functions to be rendered into an html file and ultimately displayed as a webpage.
If you know a programming language, then you can see that this is very doable, but parsing through the markdown file and creating the object doesn't sound very fun at all! /cringe
gray-matter
to the rescue!
Here's a simple example of my helper functions in TypeScript:
// lib/posts.ts import fs from "fs"; import path from "path"; import matter from "gray-matter"; const postsDirectory = path.join(process.cwd(), "src/content/posts"); export interface Post { slug: string; title: string; date: string; description: string; content: string; } export function getAllPosts(): Post[] { const fileNames = fs.readdirSync(postsDirectory); const allPostsData = fileNames .filter((fileName) => fileName.endsWith(".md")) .map((fileName) => { const slug = fileName.replace(/\.md$/, ""); const fullPath = path.join(postsDirectory, fileName); const fileContents = fs.readFileSync(fullPath, "utf8"); const matterResult = matter(fileContents); return { slug, title: matterResult.data.title, date: matterResult.data.date, description: matterResult.data.description, content: matterResult.content, }; }); return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1)); } export function getPostBySlug(slug: string): Post | null { try { const fullPath = path.join(postsDirectory, `${slug}.md`); const fileContents = fs.readFileSync(fullPath, "utf8"); const matterResult = matter(fileContents); return { slug, title: matterResult.data.title, date: matterResult.data.date, description: matterResult.data.description, content: matterResult.content, }; } catch { return null; } }
We can now safely cross #2 and #3 off our list!
Don't forget to commit changes as we go
Next.js Dynamic Routes
Next.js dynamic routes allow us to create pages that are generated at build time based on the data in your data source. This is perfect for creating a blog where each post is a separate page.
Since Next.js app router uses the directory structure as the URL structure of your site, the "dynamic" part of dynamic routes is achieved by using square brackets []
in the directory name. Think of it as a placeholder for the dynamic part of the URL. Like a variable in a programming language.
We begin by creating the directories for our blog.
# create the blog directory mkdir src/app/blog # create the dynamic route directory mkdir src/app/blog/[slug]
With our new directories, we've allowed Next.js to generate the following URL structure:
https://domain.com/blog
https://domain.com/blog/post-title
Next, we need to create the page files where we define the components to render those pages.
# create the static pages touch src/app/blog/page.tsx # create the dynamic route page touch src/app/blog/[slug]/page.tsx
Here, src/app/blog/page.tsx
will render a list of all blog posts.
And, src/app/blog/[slug]/page.tsx
will render the content of a single blog post.
Now our directory structure looks something like the following:
project ├── src │ ├── app │ │ ├── blog │ │ │ ├── [slug] │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── content │ │ └── posts │ └── lib │ └── posts.ts
Rendering Blog Page
Now we have everything we need to complete the rest of our tasks, starting with #4 on our list
// src/app/blog/page.tsx import Link from "next/link"; import { getAllPosts } from "@/lib/posts"; export default function BlogPage() { const posts = getAllPosts(); return ( <div className="min-h-screen bg-black text-white pt-24 pb-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <h1 className="text-4xl font-bold mb-8">My Blog</h1> <div className="space-y-4"> {posts.map((post) => ( <div key={post.slug} className="border border-gray-700 p-4 rounded"> <h2 className="text-2xl"> <Link href={`/blog/${post.slug}`} className="text-blue-400 hover:underline" > {post.title} </Link> </h2> <p className="text-gray-400">{post.date}</p> <p>{post.description}</p> </div> ))} </div> </div> </div> ); }
We're almost done!
Rendering Individual Posts
Our blog page lists a link to individual post via href={'/blog/${post.slug}'}
prop of the Link
component. But, this does not yet exist.
How do we achieve this? We have post objects with data presented in markdown format, but React doesn't render markdown. It renders html. Parsing the markdown and converting them into html elements is doable, but tedious!
Enters react-markdown
!
We use the react-markdown
component and pass in an object via the components
prop that maps markdown elements to its corresponding html
elements along with any TailwindCSS styling we choose.
Here is also where we supply Next.js with the list of slugs so that it knows to generate a static page per post at build time by defining the generateStaticParams
function returning the list of objects mapping slug
to the string we want the URL path to be, in our case the title of the blog post.
Here's an example taken from my blog site:
// src/app/blog/[slug]/page.tsx import { notFound } from "next/navigation"; import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { getPostBySlug, getAllPosts } from "@/lib/posts"; export async function generateStaticParams() { const posts = getAllPosts(); return posts.map((post) => ({ slug: post.slug, })); } interface PageProps { params: Promise<{ slug: string; }>; } export default async function BlogPostPage({ params }: PageProps) { const { slug } = await params; const post = getPostBySlug(slug); if (!post) { notFound(); } return ( <div className="min-h-screen bg-black text-white pt-24 pb-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <article className="max-w-4xl mx-auto"> <header className="mb-8"> <h1 className="text-4xl font-bold mb-2">{post.title}</h1> <p className="text-gray-400">{post.date}</p> <p className="text-gray-300 mt-2">{post.description}</p> </header> <hr className="border-gray-600 my-8" /> <div className="max-w-none"> <ReactMarkdown components={{ h1: ({ children }) => ( <h1 className="text-4xl font-bold mb-4 text-white"> {children} </h1> ), h2: ({ children }) => ( <h2 className="text-3xl font-semibold mb-3 text-white"> {children} </h2> ), h3: ({ children }) => ( <h3 className="text-2xl font-medium mb-2 text-white"> {children} </h3> ), h4: ({ children }) => ( <h4 className="text-xl font-medium mb-2 text-white"> {children} </h4> ), h5: ({ children }) => ( <h5 className="text-lg font-medium mb-2 text-white"> {children} </h5> ), h6: ({ children }) => ( <h6 className="text-base font-medium mb-2 text-white"> {children} </h6> ), p: ({ children }) => ( <p className="mb-4 text-gray-300 leading-relaxed"> {children} </p> ), ul: ({ children }) => ( <ul className="list-disc pl-12 mb-4 text-gray-300 space-y-1"> {children} </ul> ), ol: ({ children }) => ( <ol className="list-decimal pl-12 mb-4 text-gray-300 space-y-1"> {children} </ol> ), li: ({ children }) => <li>{children}</li>, blockquote: ({ children }) => ( <div className="pl-8"> <blockquote className="border-l-4 border-gray-500 pl-3 italic text-gray-300 mb-4"> {children} </blockquote> </div> ), strong: ({ children }) => ( <strong className="font-bold text-white">{children}</strong> ), em: ({ children }) => ( <em className="italic text-gray-300">{children}</em> ), u: ({ children }) => ( <u className="underline text-gray-300">{children}</u> ), code({ className, children, ...props }) { const match = /language-(\w+)/.exec(className || ""); return match ? ( <div className="my-4 pl-8"> <SyntaxHighlighter // eslint-disable-next-line @typescript-eslint/no-explicit-any style={oneDark as any} language={match[1]} PreTag="div" > {String(children).replace(/\n$/, "")} </SyntaxHighlighter> </div> ) : ( <code className="bg-gray-800 px-1 py-0.5 rounded text-sm font-mono" {...props} > {children} </code> ); }, }} > {post.content} </ReactMarkdown> </div> </article> </div> </div> ); }
In this example I also use react-syntax-highlighter
to beautifully display code snippets you see on this post.
And, just like that, we can cross off #4 through #7 off our task list!
Talk about standing on the shoulders of giants!! 🔥
Gotta love the open-source community! ❤️
Testing
Let's check the fruits of our labor!
Add a sample blog post for testing purposes
touch welcome-to-my-blog.md
Paste the following into the newly created markdown file:
--- title: Welcome to My Blog date: "2024-01-01" description: "An introduction to my personal blog." --- Hello, world! This is my first blog post. ## What to Expect I will be posting about: - Thoughts on technology - Personal projects - Life updates ## Exmaple of numbered list 1. First 2. Second 3. Third ## Example of quoted text > Pen is mightier than the sword. ### Example of code block ```javascript console.log("Hello, World!"); ``` ```python print("Hello, World!") ``` ## Example of bold and italic **Bold** _Italic_ Stay tuned for more!
Then, if you haven't already, start the development server and access the site from a browser
npm run dev ▲ Next.js 15.4.6 (Turbopack) - Local: http://localhost:3000 # cmd/cntl click - Network: http://192.168.1.40:3000 ✓ Starting... ✓ Ready in 1640ms
Deployment
I hope you've been committing incremental changes, but if you haven't, it's ok. You should do it now, and push to your remote branch
git add . git commit -m "Simple blog feature" git push origin feature/blog
Next.js plays really well with Vercel. If you have it setup to deploy upon push, Vercel will automatically test build the changes you've made on the branch and deploy it in parallel to your main branch as a test deploy to make sure everything is working well before merging into main.
Vercel made deploying a Next.js project so easy that all you need to do is follow the instructions on the screen and you're set. This blog is published on Vercel, and it took no time to setup continuous deployment using my github repository. I highly recommend it!
What's next?
Well, now you have a functioning blog. Time to write, publish, share! At the same time, incrementally improve the appearance and add features. Add the ability to include images to your posts, or whatever else you can think of! Isn't this great!? You can make this your own and share it with the world; literally.
As we use such a simplistic solution, I'm sure there will be a plethora of problems that will arise from using such a simple solution for a blog, but that means we get to solve as we encounter them, instead of implementing something complicated trying to solve a problem that does not yet exists.
And when the need for a more sophisticated solution does arise, you know what that means? We get to start a new project! 😃
See you next time and happy writing, building, and sharing! 👋