How I Built My Blog ?
There is a lot of content on the web about building a blog, especially with Next.js, and many approaches differ slightly. My process for building mine was to pick elements I liked from various methods and combine them to create a unique design for myself. Since I was inspired by other sources, I want to share what I learned and provide some help if you're interested in building your own.
What did I want from my blog?
- A simple process: Writing isn’t natural for me, so I wanted to minimize friction in creating blog posts and be able to focus entirely on writing.
- Integration with my website: I already had a personal website with links and a project list, so I wanted everything to be in one place.
- Fast and SEO-ready: Although I’m new to SEO, I wanted good visibility for my blog so people could discover my writing. Writing is fun, but it’s even better when it helps others! 😁
Since my website was already built with Next.js, I won’t cover the choice of framework here. I'll assume you're here to discover how I built my blog with Next.js.
To make things simple, I wanted to write my articles in Markdown because it's portable, easy to edit, and I’m familiar with it. So, in my Next.js application, I created a specific folder to store all my blog posts, with each file representing a blog post.
Folder structure
Blog post file
In each blog post file, I use the following structure:
--- title: How I Built My Blog date: 2024-11-12 tags: ["blog", "react", "nextjs", "mdx", "shiki", "seo"] hidden: false --- # How I Built My Blog Content of the blog post...
The part between ---
is the metadata of the blog post, which includes the title, date, tags, and visibility. The rest is the content of the post. This helps a lot with SEO.
Next, I created a blog
folder inside my app
directory in the Next.js application. This folder is loaded when navigating to the /blog
path.
Then, I created two functions in /lib/post.ts
where I handle the blog’s logic.
First, I defined a Post
type for all the information about a blog post:
export type Post = { meta: { title: string; description: string; image: string; tags?: string[]; date: string; hidden?: boolean; }; slug: string; content: string; };
This type helps me manage my posts and display them easily in TypeScript.
To parse each blog post file, I created the following function and used the gray-matter
package to extract the metadata from each file:
async function parseBlogPostFile(fileName: string): Promise<Post> { const filePath = path.join(blogPath, fileName); const slug = fileName.replace(".mdx", ""); const fileContent = await readFile(filePath, "utf-8"); const { content, data } = matter(fileContent); return { meta: { title: data.title, description: data.description, image: data.image, tags: data.tags, date: data.date, hidden: data.hidden, }, slug: slug, content: content, }; }
If you visit the blog page, you’ll see all my blog posts. I load them with the following function:
export const getAllMyBlogPost = async (): Promise<Array<Post>> => { const fileNames = await readdir(blogPath); const promisePosts = fileNames.map(async (filename) => { return await parseBlogPostFile(filename); }); const posts = await Promise.all(promisePosts); return posts.filter((post) => !post.meta.hidden); };
This is a very basic function. It fetches all my blog post files, extracts the metadata and content, and uses gray-matter
to parse metadata. I also add a filter at the end to show only posts that aren’t hidden.
The slug
is simply the file name without the .mdx
extension, and it becomes the URL path for the blog post.
I also created a function to get a specific blog post:
export async function getPost(slug: string): Promise<Post> { const fileName = `${slug}.mdx`; return await parseBlogPostFile(fileName); }
With these two functions, I have exactly what I need, and they’re easy to use in my components.
Thanks to Next.js's server-side rendering, I can call these functions directly in my components to retrieve all the data I need without building an API.
Here’s an example of the page.tsx
file where I display all blog posts:
import { getAllMyBlogPost } from "@/lib/post"; import dayjs from "dayjs"; import Link from "next/link"; import { cache } from "react"; const getAllMyBlogPostCache = cache(async () => { return await getAllMyBlogPost(); }); export default async function Blog() { const posts = await getAllMyBlogPostCache(); posts.sort((a, b) => dayjs(b.meta.date).unix() - dayjs(a.meta.date).unix()); return ( <div className="flex flex-col gap-4 mt-5"> {posts.map((post, idx) => ( <Link className="text-start no-underline hover:underline" key={idx} href={"/blog/" + post.slug} > <div className="flex gap-3 w-full"> <div className="text-gray-500 w-1/4"> {dayjs(post.meta.date).format("MMMM D, YYYY")} </div> <div className="w-3/4">{post.meta.title}</div> </div> </Link> ))} </div> ); }
This component lists blog posts sorted by date with a link to each post. I also added caching to avoid reloading all posts every time.
And here’s the page where I display a specific blog post:
import { getPost } from "@/lib/post"; import { MDXRemote } from "next-mdx-remote/rsc"; import { MDXComponents } from "mdx/types"; import Code from "@/components/Code"; import dayjs from "dayjs"; import Link from "next/link"; import { Metadata } from "next"; import { cache } from "react"; const getPostCache = cache(async (slug: string) => { return await getPost(slug); }); export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }) { const post = await getPostCache((await params).slug); return { title: post.meta.title, description: post.meta.description, image: post.meta.image, keywords: post.meta.tags?.join(","), robots: "index, follow", authors: ["cchalop1"], openGraph: { title: post.meta.title, description: post.meta.description, image: post.meta.image, type: "article", site_name: "cchalop1 blog", }, twitter: { card: "summary_large_image", site: "@cchalop1", title: post.meta.title, description: post.meta.description, image: post.meta.image, }, } as Metadata; } export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const post = await getPostCache((await params).slug); const components: MDXComponents = { h1: (props) => ( <h1 className="font-bold text-2xl lg:text-4xl">{props.children}</h1> ), code: (props) => { if (props.className === undefined) { return <code>{props.children}</code>; } const lang = props.className!.replace("language-", ""); const content = props.children as string; return <Code content={content} lang={lang} />; }, a: (props) => <Link href={props.href as string}>{props.children}</Link>, pre: (props) => <pre className="p-0 bg-white">{props.children}</pre>, }; return ( <article className="prose ml-5 mr-5 lg:ml-0 lg:mr-0 mb-10 max-w-none"> <div className="text-gray-500"> {dayjs(post.meta.date).format("MMMM D, YYYY")} </div> <MDXRemote source={post.content} components={components} /> </article> ); }
In conclusion, I’m genuinely pleased with this architecture as it meets my goals for a straightforward, fast, and well-integrated blog. It lets me focus on writing without technical hurdles while ensuring strong visibility through optimized SEO.
If you have any questions or suggestions on how I’ve implemented this setup, please don’t hesitate to reach out! I’d be happy to discuss your ideas and hear your feedback to make this project even better.