Migrating from Gatsby to Next.js

October 20, 2025Next.js, Gatsby, React

After 5 years of running my blog on Gatsby, I migrated to Next.js. My Gatsby blog stopped working.


Why migrate?

Dependency hell. I hadn't updated packages regularly. When I ran gatsby develop, everything broke. Outdated plugins, conflicting dependencies, GraphQL version mismatches. I spent hours trying to fix it and realized I didn't want to maintain this complexity.

I needed something simpler that I could understand without reading plugin docs.

I used Vercel's Portfolio Starter Kit template. It's minimal, uses Next.js, supports Markdown, and gives me full control over SEO and URL structure.

The concern: SEO. I had 77 blog posts indexed. Breaking those URLs wasn't an option.


The migration plan

My approach:

  1. Keep the main branch as Gatsby (the live site)
  2. Create a v1 branch as a stable Gatsby archive
  3. Build Next.js on a v2 branch and iterate there

If something went wrong, the live site stayed up.

git checkout -b v1
git push origin v1

git checkout main
git checkout -b v2

I started with Vercel's Portfolio Starter Kit template. Clean and minimal.

I can add features as I need them. Want comments? Add them. Want analytics? Drop them in.


Content structure

My Gatsby setup had all blog posts in /content/posts/[post-name]/index.mdx. I kept this structure in Next.js.

content/posts/
  my-post/
    index.mdx
    image.png

The Next.js example used /app/blog/posts/ with .mdx files directly. I rewrote the parsing logic to read from my existing structure instead. This saved me from moving 77 files around.


Frontmatter cleanup

Gatsby posts had a slug field in frontmatter. In Next.js, I use the folder name as the slug, so this field was redundant. I found 26 posts with explicit slugs and removed them.

A few folder names didn't match their slugs, so I renamed them:

  • debounce-method-in-javascriptdebounce-in-javascript
  • react-text-to-speech-componentreact-text-to-speech
  • And 2 more

The new minimal frontmatter:

---
title: Post Title
date: 2024-01-01
description: Post description
tags:
  - React
  - Next.js
---

Simple.


The SEO decision

The Next.js blog example uses /blog/[slug] URLs. My Gatsby site used /[slug] (posts at root level). Changing this would break every indexed URL.

I moved app/blog/[slug]/page.tsxapp/[slug]/page.tsx and updated:

  • app/components/posts.tsx (internal links)
  • app/sitemap.ts (sitemap generation)
  • app/rss/route.ts (RSS feed)

This preserved all my URLs.


Static assets

Images in Gatsby lived alongside the MDX files. In Next.js, they need to be in /public.

I wrote a script to:

  1. Move images from content/posts/[post]/image.pngpublic/posts/[post]/image.png
  2. Update MDX references: ![](./image.png)![](/posts/[post]/image.png)

The script saved hours of manual work.


Dynamic OG images

My Gatsby posts had static banner images for social sharing. Each one was manually created in Figma, exported, and committed to the repo. 77 posts × ~200KB per image = 15MB of banner images.

I switched to dynamic generation.

Created /app/og/route.tsx:

import { ImageResponse } from 'next/og';
import { readFile } from 'fs/promises';
import { join } from 'path';

export async function GET(request: Request) {
  let url = new URL(request.url);
  let title = url.searchParams.get('title') || 'Edvins Antonovs';

  const geistRegular = await readFile(join(process.cwd(), 'public', 'fonts', 'Geist-Regular.ttf'));

  return new ImageResponse(
    (
      <div tw="flex flex-col w-full h-full items-center justify-center bg-black px-16">
        <div tw="flex flex-col w-full">
          <h1 style={{ fontFamily: 'Geist' }} tw="text-7xl font-bold text-white leading-tight mb-8">
            {title}
          </h1>
          <p style={{ fontFamily: 'Geist' }} tw="text-3xl text-gray-400">
            edvins.io
          </p>
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Geist',
          data: geistRegular,
          style: 'normal',
          weight: 400,
        },
      ],
    }
  );
}

The design is minimalist: black background, white title in large Geist font, with "edvins.io" in smaller gray text below. Clean and professional.

Using Geist font

To match my site's branding, I wanted to use Geist (Vercel's font) in the OG images. The trick is to download the font locally rather than fetching it from a CDN:

mkdir -p public/fonts
curl -L "https://github.com/vercel/geist-font/raw/main/packages/next/dist/fonts/geist-sans/Geist-Regular.ttf" -o public/fonts/Geist-Regular.ttf

Using readFile to load the local font is more reliable than fetching from external URLs.

Now every post gets an OG image at /og?title=Post+Title.

Benefits:

  • No more opening Figma for every post
  • Repo size dropped by 15MB
  • Faster builds (no image processing)
  • Consistent design across all posts
  • Easy to update the template once
  • Uses the same font as the website

The images are generated on-demand and cached by Vercel.


Interactive components

This was the trickiest part. I had 5 posts with interactive React components embedded in MDX:

  • GoldMine (Clash of Clans building demo)
  • CultivateDemo (habit tracker with video)
  • Preview (HTML table generator)
  • TextToSpeech + BlogPost

Gatsby let me import these directly in MDX. Next.js with next-mdx-remote needs explicit registration.

I moved components to app/components/blog/, added 'use client', then registered them:

import GoldMine from './blog/GoldMine';
import CultivateDemo from './blog/CultivateDemo';

let components = {
  h1: createHeading(1),
  // ... other components
  GoldMine,
  CultivateDemo,
};

export function CustomMDX(props) {
  return <MDXRemote {...props} components={components} />;
}

Now they work just like before.


Static pages

I had 4 static pages: About, Projects, Books, Self-Education. These lived in /content/pages/ in Gatsby.

I created a utility to read them:

export function getPage(slug: string) {
  const pagePath = path.join(process.cwd(), 'content', 'pages', slug, 'index.mdx');
  // Parse frontmatter and return content
}

Then created routes:

  • app/about/page.tsx
  • app/projects/page.tsx
  • app/books/page.tsx
  • app/self-education/page.tsx

Each one calls getPage() and renders with CustomMDX.


Markdown tables

My Books page had a big table with 100+ entries. It rendered fine in Gatsby but broke in Next.js.

The fix: install remark-gfm (GitHub Flavored Markdown):

npm install remark-gfm@3

Note: Version 3, not 4. Version 4 has compatibility issues with next-mdx-remote@4.x.

Updated MDX rendering:

import remarkGfm from 'remark-gfm';

export function CustomMDX(props) {
  return <MDXRemote {...props} options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }} />;
}

Tables worked again. But they looked terrible on mobile with my narrow page width. I converted the Books page to a list grouped by year instead.


Metadata utility

Every page needs consistent metadata (titles, descriptions, OG images, Twitter cards). I created a helper:

export function generatePageMetadata({
  title,
  description,
  path,
  type,
  publishedTime,
  tags,
}: PageMetadataProps): Metadata {
  const ogImage = `${baseUrl}/og?title=${encodeURIComponent(title)}`;

  return {
    title,
    description,
    openGraph: {
      title,
      description,
      url: `${baseUrl}${path}`,
      siteName: 'Edvins Antonovs',
      images: [{ url: ogImage }],
      ...(publishedTime && { publishedTime }),
      ...(tags && { tags }),
    },
    twitter: {
      card: 'summary_large_image',
      images: [ogImage],
    },
  };
}

Now every page uses this. One place to update, everywhere stays consistent.


What I didn't migrate

I dropped:

  • PWA manifest (who installs personal blogs as apps?)
  • Multiple favicon sizes (just kept favicon.ico, icon.svg, apple-icon.png)
  • Various Gatsby plugins I never used

What I learned

Write scripts for repetitive tasks. I made Node scripts to move images and clean frontmatter. Saved hours.

Migrate in steps. Blog posts first, then pages, then components. Test each part before moving on.

Keep the old version. I archived the Gatsby site in a branch so I could reference it anytime.

Plan URLs first. I kept my URL structure (/[slug] instead of /blog/[slug]) to preserve SEO. Changing URLs later would break everything.

Drop what you don't need. I removed half my Gatsby features. The site is better without them.


Was it worth it?

Yes. The migration took a day spread over a week.

The Next.js code is cleaner. Builds are faster. I understand how my blog works now.

I can add features as I need them. No plugin mess. No GraphQL fights. Just Next.js and Markdown files.

If you're stuck in Gatsby dependency hell, start fresh. Use a minimal template. Keep your content structure and URLs. Build up from there.

The full code is on GitHub if you want to see how it all fits together.