Building This Blog: A Week-Long Journey

The story of planning, building, and shipping a full-featured blogging system in Next.js 15 — complete with MDX, syntax highlighting, and a few surprises along the way.

7 min read
metanext.jsweb-development

Planning the blog system

I didn't know I'd be building a blogging system this week. Nobody did.

Well, that's not entirely true. I planned to build one. I just didn't know it would actually work.

But here we are. You're reading this on a blog that didn't exist seven days ago — a blog built from scratch with Next.js 15, MDX, and more optimism than common sense.

Let me tell you how it happened.

The planning phase (or: how I learned to stop worrying and pick MDX)

It started with a question: "How hard could it be to add a blog to my portfolio?"

Spoiler: harder than I thought, easier than I feared.

I sat down and wrote out a plan. Not just any plan — a comprehensive plan. The kind of plan that makes you feel productive before you've written a single line of code. I called it BLOG_SYSTEM_PLAN_SPEC.md, because I'm the kind of person who writes spec documents for personal projects.

The plan was simple: roughly one post per week, nothing crazy. Static generation. MDX for rich content. Syntax highlighting for code blocks. RSS feed for the purists. Draft mode for me to write in peace.

But here's where it got interesting.

I had to choose how to process MDX. Three options stared back at me:

  1. @next/mdx — baked into Next.js, but requires webpack config tweaks
  2. mdx-bundler — powerful, but heavy and complex
  3. next-mdx-remote/rsc — lightweight, React Server Component support, zero config

I went with option three. Why? Because I'm lazy. And because React Server Components are the future, apparently. Plus, zero config sounded a lot better than "some config" or "config hell."

Best decision I made all week.

The implementation (or: when TypeScript fights back)

With the plan locked in, I started building.

First came the data layer: schemas, filesystem utilities, caching with React's cache() function. Then the components: PostCard, PostSearch, PostHeader. All the pieces you need to make a blog feel like a blog.

Then came the routing. Next.js 15's App Router is a beautiful thing — when it works. Server Components for the listing page. Dynamic routes for individual posts. Static generation via generateStaticParams(). It was all coming together.

Until TypeScript decided to ruin my day.

// This looked fine to me
import type { MDXComponents } from "mdx/types";
 
export const mdxComponents: MDXComponents = {
  a: ({ href, children, ...props }) => { ... }
};

TypeScript disagreed. Violently.

error TS2307: Cannot find module 'mdx/types'
error TS7031: Binding element 'href' implicitly has an 'any' type

Twenty-two errors. Twenty-two ways TypeScript told me I was wrong.

Turns out mdx/types doesn't exist in this setup. The fix? Use React's native types instead:

import type { ComponentPropsWithoutRef } from "react";
 
export const mdxComponents = {
  a: ({ href, children, ...props }: ComponentPropsWithoutRef<"a">) => { ... }
};

Boom. Fixed. TypeScript and I were friends again.

The rendering magic (or: why rehype-pretty-code is perfect)

MDX rendering in action

The heart of any blog is how it renders content. For me, that meant MDX with GitHub-flavored Markdown and syntax highlighting.

I chose rehype-pretty-code over the usual suspects (rehype-prism-plus, etc.) because it's modern, actively maintained, and works beautifully with Next.js 15. It uses shiki under the hood — the same syntax highlighter that powers VS Code.

The setup was dead simple:

import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import rehypePrettyCode from "rehype-pretty-code";
 
<MDXRemote
  source={content}
  components={mdxComponents}
  options={{
    mdxOptions: {
      remarkPlugins: [remarkGfm],
      rehypePlugins: [
        [rehypePrettyCode, {
          theme: "github-dark",
          keepBackground: true,
        }]
      ],
    },
  }}
/>

Code blocks now look gorgeous. Line numbers, syntax colors, highlighted lines — the works. And because I'm using shiki, I get every language under the sun supported out of the box.

Though I did have to explicitly install shiki as a dependency after the terminal threw a warning at me. Peer dependencies, am I right?

The styling challenge (or: making prose not look like garbage)

Here's the thing about blog content: it needs to look good. Not flashy. Not over-designed. Just... readable.

I reached for @tailwindcss/typography and its magic .prose class. But the defaults weren't quite right for my portfolio's design system. So I customized.

I extended the Tailwind config with custom prose styles:

.prose a {
  color: var(--color-primary-blue);
  text-decoration: underline;
  text-decoration-color: rgba(0, 191, 255, 0.3);
}
 
.prose code {
  color: var(--color-primary-coral);
  font-weight: 500;
}

And I styled code blocks with line numbers using CSS counters:

pre code > [data-line]::before {
  counter-increment: line;
  content: counter(line);
  display: inline-block;
  width: 1.5rem;
  margin-right: 1.5rem;
  text-align: right;
  color: rgba(255, 255, 255, 0.3);
}

The result? Blog posts that feel like they belong on this site, not like they were bolted on as an afterthought.

The extras (or: RSS feeds and OG images and sitemaps, oh my)

A blog isn't complete without the supporting cast:

RSS feed — because some people still use RSS readers (and I respect them for it). Built as a route handler at /blog/rss.xml. RSS 2.0 format. XML escaping. Caching headers. The works.

OpenGraph images — dynamically generated for every post using Next.js's ImageResponse API. Title, summary, gradient background. Looks great when you share a post on social media.

Sitemap — all blog posts automatically added to /sitemap.xml with proper priorities and change frequencies. Google loves it. I love Google loving it.

Draft mode — posts with draft: true are visible locally but completely excluded in production. No environment variable setup. No complex logic. Just works.

The testing moment (or: does this thing actually work?)

With everything in place, I ran the test:

pnpm blog:new "Welcome to My Blog"

A new post appeared in content/blog/. I added some content, ran pnpm blog:lint to validate the metadata, and fired up the dev server.

The first page load took 3 seconds. My heart sank. Then I realized: that's just the initial compile. The second load? 176 milliseconds.

Fast.

I navigated to /blog. There was my post. I clicked it. There was my content. Syntax highlighting worked. Images loaded (well, after I added them). Draft badge showed up. Reading time calculated correctly.

It. All. Worked.

What I learned (or: reflections on a week well spent)

Building this blogging system taught me a few things:

  1. Planning matters — that spec document saved me hours of second-guessing
  2. React Server Components are powerful — zero client-side JavaScript for blog content
  3. TypeScript is your friend — even when it's yelling at you
  4. Simple is better — flat file structure beats complexity for 52 posts/year
  5. Modern tools are incrediblenext-mdx-remote, rehype-pretty-code, Next.js 15 — they all just work

I also learned that building your own blog, from scratch, is deeply satisfying. Sure, I could have used WordPress or Medium or Substack. But then I wouldn't have this.

And "this" is pretty damn good.

What's next

Now that the system is built, I can focus on what matters: writing.

I plan to publish roughly once a week. Topics will range from software development to AI to whatever catches my attention. Technical posts, personal reflections, maybe some tutorials.

The system is ready. The canvas is blank.

Let's see what happens.


If you made it this far, thanks for reading. This is my first post on this blog, and it felt right to make it about the blog itself. Meta, I know. But hey, we all start somewhere.

Got questions about the implementation? Want to know more about specific technical decisions? Let me know.