6 min

Migrating My Blog

blognextjsmdxtailwindcssarchitecture

Why Migrate the Blog

I've had a blog for a while. But the writing experience never felt right. I wanted to write on my own domain, my own way, with less friction. Drop an MDX file, and it becomes a post. That level of simplicity is what I was after.

This time, I rebuilt the blog from scratch with Next.js 16 + MDX + Tailwind CSS v4. It's designed as infrastructure for "focusing on writing"—keeping the writing experience as simple as possible while delivering fast, well-designed pages to readers. Here's how the technology makes that balance work.

Directory Structure

project structure
.
├── app/                        # Next.js App Router
│   └── [locale]/
│       └── blog/
│           └── [slug]/
│               ├── page.tsx            # Blog post page (RSC)
│               └── opengraph-image.tsx # OG image (build-time)
├── content/blog/               # Blog posts (MDX files)
│   └── hello-world/
│       ├── ja.mdx              # Japanese version
│       └── en.mdx              # English version
├── components/blog/            # Blog-specific components
├── lib/
│   ├── blog.ts                 # Post metadata, pagination
│   ├── mdx.ts                  # MDX compilation pipeline
│   └── image-upload.ts         # Image optimization pipeline
└── public/images/blog/         # Local image fallback (dev)

The key is the content/blog/ directory. Each post is a directory named by its slug, containing ja.mdx and en.mdx. The filesystem itself becomes the CMS. No database.

MDX as the Writing Format

MDX lets you embed JSX in Markdown. Write metadata in frontmatter, write the body in Markdown. That's all it takes to create a post.

frontmatter example
title: "Migrating My Blog"
description: "Rebuilt the blog from scratch"
date: "2026-03-16"
tags: ["blog", "nextjs"]
published: true

Set published: false and it stays a draft—won't appear even after deployment. One simple flag controls visibility.

Code blocks are highlighted by Shiki at build time. Zero JavaScript runs at runtime. Both light and dark themes are pre-generated.

lib/mdx.ts
const prettyCodeOptions: PrettyCodeOptions = {
  theme: {
    dark: "github-dark-dimmed",
    light: "github-light",
  },
  keepBackground: false,
};

Math formulas are supported via KaTeX:

×E=Bt\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}

Image Pipeline: Just Paste and Done

When writing a blog, handling images is one of the most tedious tasks. Take a screenshot → resize → convert to WebP → place in the right directory → write the path in Markdown. I automated all of this.

Just Ctrl+V an image into the editor textarea. Everything else happens automatically:

  1. Extract image file from clipboard
  2. Insert placeholder (![uploading...]()) at cursor position
  3. Optimize with Sharp (WebP conversion, max width 1920px, quality 75%)
  4. Upload to Cloudflare R2 (local filesystem in development)
  5. Replace placeholder with actual Markdown image syntax
lib/image-upload.ts
export async function optimizeImage(buffer: Buffer) {
  const image = sharp(buffer);
  const metadata = await image.metadata();
 
  const needsResize = metadata.width !== undefined && metadata.width > 1920;
  const pipeline = needsResize ? image.resize({ width: 1920 }) : image;
 
  return pipeline
    .webp({ quality: 75, effort: 4 })
    .toBuffer({ resolveWithObject: true });
}

For security, not only MIME types but also magic bytes are verified through Sharp to reject spoofed files. File size is capped at 3MB. Uploads are limited to 2 concurrent operations.

As a writer, the only thing you think about is "Ctrl+V." Everything else is handled by the system.

SSG: Zero-Runtime Static Site

This blog is a fully static site. generateStaticParams generates all post pages at build time.

app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const slugs = getPostSlugs();
  const params: { locale: string; slug: string }[] = [];
  for (const slug of slugs) {
    for (const locale of ["ja", "en"]) {
      if (fs.existsSync(`content/blog/${slug}/${locale}.mdx`)) {
        params.push({ locale, slug });
      }
    }
  }
  return params;
}

MDX compilation, Shiki syntax highlighting, KaTeX math rendering—it all completes at build time. The user's browser only displays static HTML generated by React Server Components.

I don't use ISR (Incremental Static Regeneration). Update an article, deploy. That's it. Simplicity wins.

Auto-Generated OGP Images

OG images for social sharing are also auto-generated at build time for every post. This uses Next.js ImageResponse (Satori-based).

opengraph-image.tsx
export default async function OGImage({ params }) {
  const { locale, slug } = await params;
  const post = getPost(slug, locale as Locale);
 
  return new ImageResponse(
    <div style={{
      background: "linear-gradient(135deg, #0f172a, #1e293b)",
      // title, description, site name, date...
    }}>
      {post.meta.title}
    </div>,
    { width: 1200, height: 630 }
  );
}

The post's title, description, and date are read from frontmatter and output as a 1200x630px PNG. No need to design in Figma. Write the post and OGP comes for free.

Additionally, structured data (JSON-LD) for TechArticle, BreadcrumbList, and FAQ is auto-generated. The only thing you need to do for SEO is write thorough frontmatter.

Design System

Specifying colors or fonts on individual elements is strictly prohibited. Everything is unified through CSS variables and Tailwind CSS v4 utility classes.

app/globals.css (excerpt)
:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.546 0.245 262.881);
  --muted: oklch(0.97 0 0);
}
 
.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
}

Fonts are Noto Sans JP (Japanese) paired with Inter (Latin). For Japanese typography, word-break: auto-phrase and line-break: strict are applied for natural line breaks.

Dark mode is just a CSS custom property switch. Not a single line of dark-mode-aware code exists in the components.

Internationalization (i18n)

Two languages—Japanese and English—are supported via next-intl. Just place ja.mdx and en.mdx per post, and URLs automatically become /ja/blog/slug and /en/blog/slug.

The sitemap gets hreflang attributes automatically, notifying Google Search of each language variant. Canonical URLs are set correctly per locale.

Summary

What I wanted from this migration was summed up in one phrase: "zero friction for writing."

  • Drop one MDX file and it's a post
  • Paste images and they're optimized and delivered automatically
  • OGP and SEO are generated at build time
  • Design is managed consistently through theme variables
  • Add a language by adding a file

Every frustration I had with the previous blog is now resolved. Technology is a means, not an end. But the right technical choices can bring the barrier to writing asymptotically close to zero. I'm glad I made the switch.