A Walkthrough of migrating MaxRozen.com from Gatsby to Next.js

I've been super keen on Gatsby.js since about 2017. Back then, I re-wrote this blog from a clunky old Django site that required an AWS EC2 instance running 24/7 to Gatsby, and it was amazing.

Over time, I've come to notice that Gatsby's core doesn't actually do much, and each project needs a different set of plugins to do what you want, and every new project with Gatsby has completely different headaches, so you never actually get better at using it (as a casual user).

Table of contents

Background info

So for some background information, MaxRozen.com is where I write weekly articles about React, and occasionally launch products (my most recent being useEffectByExample.com).

Content Folder Structure

When I used Gatsby, all of my content lived in a content/ folder, with a folder named after the date for each post, and the folder containing the markdown and images for the post.

Basically, something like this:

content/
|-- 2021-01-01/
| |-- index.md
| |-- cool-image.jpg
|-- 2021-01-08/
| |-- index.md
| |-- some-other-image.jpg

Besides that, my site was thankfully pretty straightforward, I had:

  • A single template for my articles
  • A few pages (home, articles/, newsletter/, uses/)

For styling, I just used Emotion (CSS in JS) with Tailwind via twin.macro (you can read more about that here)

Plugins

I eventually settled on the following core Gatsby plugins for any of my content sites:

  • gatsby-plugin-canonical-urls
  • gatsby-plugin-emotion
  • gatsby-transformer-remark
    • gatsby-remark-autolink-headers
      • gatsby-remark-prismjs
  • gatsby-plugin-feed
  • gatsby-plugin-react-helmet
  • gatsby-plugin-no-javascript
  • gatsby-plugin-no-javascript-utils

Since I've come to rely heavily on these plugins in Gatsby, I wanted to figure out how to get the same functionality in Next.js.

If you're keen to find out how I did it, read on.

Migrating to Next

Since I was already using React, very little in my site actually needed to change.

Most of my development effort was in replicating the plugins I loved from Gatsby, and in having to figure out what assumptions code examples in Next.js had (a few, it turns out).

First steps with Next.js

To get started with Next.js, I headed over to their repo, and dove into the examples folder. I searched for "blog", and to my surprise found exactly the example I wanted: "blog-starter-typescript".

From there, I ran create-next-app:

npx create-next-app --example blog-starter-typescript blog-starter-typescript-app
# or
yarn create next-app --example blog-starter-typescript blog-starter-typescript-app

The example came with Tailwind already, so all I had to do was install Emotion, and follow the Next.js + Emotion guide provided by twin.macro to get the same CSS-in-JS + Tailwind setup I was using with Gatsby.

Folder structure and file naming

The examples in Next.js' repo assume you keep your content like this:

content/
|-- your-articles-title.md
|-- some-other-title.md

Whereas I used to keep my content like this:

content/
|-- 2021-01-01/
| |-- index.md
| |-- cool-image.jpg
|-- 2021-01-08/
| |-- index.md
| |-- some-other-image.jpg

Since Gatsby had a plugin that could recursively dive through your content folder to find all markdown files, my preferred setup worked.

Unfortunately not the case (out of the box) with Next.js, so I had to go through and rename some files.

Deleting (almost) everything, and copying my pages across

With Tailwind and CSS in JS set up, all I had to do was copy over my pages and templates, and delete all of Gatsby's GraphQL.

Whereas in Gatsby I had a templates/article.js file to control how my articles looked, in Next.js if I didn't want my articles in a folder, I had to create a Dynamic route: pages/[slug].tsx.

Dynamic routes get their content via getStaticProps and getStaticPaths (described further here and here).

After digging through how some of the examples used these two functions, it clicked, and I figured out how to make it work for my blog:

  • getStaticPaths can be as simple as looping through a directory, and making an array of slugs
  • getStaticProps then takes the slug passed in via the URL (for this article, the slug is walkthrough-migrating-maxrozen-com-gatsby-to-nextjs), and fetches the content (and transforming MDX to HTML)

One weird thing I noticed is a slight implementation difference between Gatsby's Link and Next's Link: while Gatsby would effectively spread props, and pass every prop you provide Link down, Next had a stricter API.

So while in Gatsby this worked:

const StyledLink = tw(Link)`
flex justify-center items-center text-gray-500 hover:text-gray-600
`
<StyledLink to={'/'}>
Home
</StyledLink>

In Next.js, I had to do this:

const StyledLink = tw.a`
flex justify-center items-center text-gray-500 hover:text-gray-600
`;
<Link passHref href={'/'}>
<StyledLink>Home</StyledLink>
</Link>;

//highlight-next-line: I just want good code snippets in Next.js

It turns out that the code formatter in Gatsby was extremely smart, and required very little actual setup. Funny how it takes giving something up to really appreciate it.

For example, if you added //highlight-next-line to your code snippet, it would make the line stand out. Like this:

useEffect(() => {
const fetchData = async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
const newData = await response.json();
setData(newData);
};
fetchData();
}, [props.id]);

Because the markdown plugin could handle that for free, I never felt the urge to try out MDX (in case you're wondering what MDX is: check out the docs).

It turned out the "simplest" way to customise code formatting was to rename all of my content from .md to .mdx, and use mdx-remote (see example).

Here's how I managed to customise my code snippets in Next.js:

  • First I installed prism-react-renderer. It's a React component that lets you customise what the Prism.js code snippet output looks like.

  • Then I added a Code component using prism-react-renderer:

    //components/Code.js
    import React from 'react';
    import Highlight, { defaultProps } from 'prism-react-renderer';
    const isHighlightedLine = (line, mark = '// highlight-next-line') =>
    line?.some((prevLine) => {
    return prevLine?.content === mark;
    });
    const Code = ({ children, className }) => {
    const language = className?.replace(/language-/, '');
    return (
    <Highlight
    {...defaultProps}
    code={children}
    language={language}
    theme={undefined}
    >
    {({ className, style, tokens, getLineProps, getTokenProps }) => (
    <pre
    className={className}
    style={{ ...style }}
    data-language={language}
    >
    {tokens.map((line, i) => {
    const lineProps = getLineProps({ line, key: i });
    const classNameArr = [lineProps.className];
    if (isHighlightedLine(line)) {
    return null;
    }
    if (isHighlightedLine(tokens?.[i - 1])) {
    classNameArr.push('gatsby-highlight-code-line');
    }
    const finalLineProps = {
    ...lineProps,
    className: classNameArr.join(' '),
    };
    return (
    <div key={i} {...finalLineProps}>
    {line.map((token, key) => (
    <span key={key} {...getTokenProps({ token, key })} />
    ))}
    </div>
    );
    })}
    </pre>
    )}
    </Highlight>
    );
    };
    export default Code;

    (My site contains custom CSS that targets gatsby-highlight-code-line, and I didn't want to change it for Next.js)

  • Then in my article template ([slug].tsx), I could override the default code markup with my own:

    const components = {
    Head,
    pre: (props: any) => <div className="gatsby-highlight" {...props} />,
    code: Code,
    // ...
    };

Autolinking headers in MDX

What I really liked about my Gatsby site was how effortlessly plugins like gatsby-remark-autolink-headers worked. You installed it, and bam: all headings in your markdown content now let you grab the link to that heading.

To get the same behaviour in Next.js, I had to add the following:

  • First, install remark-slug and remark-autolink-headings:

    yarn add remark-slug remark-autolink-headings
  • Then add them to your article template:

    // `[slug].tsx`
    // ...imports etc up here
    //svgIcon shamelessly stolen from gatsby-remark-autolink-headers
    const svgIcon = `<svg aria-hidden="true" focusable="false" height="16" version="1.1" viewBox="0 0 16 16" width="16"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>`;
    //then when calling next-mdx-remote's renderToString:
    const mdxSource = await renderToString(content, {
    components,
    // Optionally pass remark/rehype plugins
    mdxOptions: {
    remarkPlugins: [
    require('remark-slug'),
    [
    require('remark-autolink-headings'),
    {
    behavior: 'prepend',
    linkProperties: {
    'aria-label': `permalink`, // this could be more useful
    class: 'anchor before',
    },
    content: {
    type: `raw`,
    value: svgIcon,
    },
    },
    ],
    ],
    rehypePlugins: [],
    },
    scope: data,
    });
  • To get the anchor to actually appear, I added the following to my global CSS:

    .anchor.before {
    position: absolute;
    top: 0;
    left: 0;
    transform: translateX(-100%);
    padding-right: 4px;
    }
    .anchor.after {
    display: inline-block;
    padding-left: 4px;
    }
    h1 .anchor svg,
    h2 .anchor svg,
    h3 .anchor svg,
    h4 .anchor svg,
    h5 .anchor svg,
    h6 .anchor svg {
    visibility: hidden;
    }
    h1:hover .anchor svg,
    h2:hover .anchor svg,
    h3:hover .anchor svg,
    h4:hover .anchor svg,
    h5:hover .anchor svg,
    h6:hover .anchor svg,
    h1 .anchor:focus svg,
    h2 .anchor:focus svg,
    h3 .anchor:focus svg,
    h4 .anchor:focus svg,
    h5 .anchor:focus svg,
    h6 .anchor:focus svg {
    visibility: visible;
    }

Scrolling to an id

By default, Next.js doesn't provide anything to make scrolling to an id work (to see what I mean, click here) - that doesn't work by default.

To work around this, I could think of two options:

  1. Use useEffect to scroll to the linked id
  2. Do it the old school way and use an event listener on DOMContentLoaded

I chose the old fashioned way, so I added this to my _document.tsx:

<script
dangerouslySetInnerHTML={{
__html: `
document.addEventListener("DOMContentLoaded", function(event) {
var hash = window.decodeURI(location.hash.replace('#', ''))
if (hash !== '') {
var element = document.getElementById(hash)
if (element) {
var scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop
var clientTop = document.documentElement.clientTop || document.body.clientTop || 0
var offset = element.getBoundingClientRect().top + scrollTop - clientTop
// Wait for the browser to finish rendering before scrolling.
setTimeout((function() {
window.scrollTo(0, offset - 0)
}), 0)
}
}
})`,
}}
></script>

Sitemap

Creating a sitemap in Next.js can be done by iterating through your markdown files, the files in the pages/ directory, and wrapping some XML around them.

Here's how I did it:

  • In your Next.js app's root folder, create a new folder called scripts, and create a file called generateSitemap.js

  • Paste the following, and change the domain name as needed:

    // scripts/generateSitemap.js
    const fs = require('fs');
    const globby = require('globby');
    const prettier = require('prettier');
    (async () => {
    const prettierConfig = await prettier.resolveConfig('./.prettierrc');
    //exclude pages below by adding ! in front of the path
    const pages = await globby([
    'pages/*.tsx',
    '_content/articles/*.mdx',
    '!pages/404.tsx',
    '!pages/_*.tsx',
    '!pages/[*.tsx',
    '!pages/api',
    ]);
    const sitemap = `
    <?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    ${pages
    .map((page) => {
    const path = page
    .replace('pages', '')
    .replace('_posts', '')
    .replace('.tsx', '')
    .replace('.mdx', '');
    const route = path === '/index' ? '' : path;
    return `
    <url>
    <loc>${`https://YOURDOMAINHERE.com${route}`}</loc>
    <changefreq>daily</changefreq>
    <priority>0.7</priority>
    </url>
    `;
    })
    .join('')}
    </urlset>
    `;
    const formatted = prettier.format(sitemap, {
    ...prettierConfig,
    parser: 'html',
    });
    // eslint-disable-next-line no-sync
    fs.writeFileSync('public/sitemap.xml', formatted);
    })();
  • Then add the following to your next.config.js:

    // next.config.js
    module.exports = {
    // modify your webpack export if you've already got one
    webpack: (config, { isServer }) => {
    if (isServer) {
    require('./scripts/generateSitemap');
    }
    return config;
    },
    };
  • Finally, add public/sitemap.xml to your .gitignore

This way, Next will only generate the sitemap as part of the build, and not while you're developing/writing articles.

RSS Feed in Next.js

I originally planned on copying the gatsby plugin to recreate my site's RSS feed, before I ran into this blog post by Florian.

The gist of the blog post is to use Feed, an npm package that abstracts away the pain of generating RSS, Atom, and JSON feeds.

Here's how I did it:

  • In the scripts folder we created for the sitemap, create a file called generateRss.js

  • Install the following packages (if you're not using markdown files, all you need is feed):

    yarn install feed gray-matter remark remark-html
  • Paste the following, and update the metadata:

    const Feed = require('feed').Feed;
    const fs = require('fs');
    const path = require('path');
    const matter = require('gray-matter');
    const postsDirectory = path.join(process.cwd(), '_content/articles');
    const remark = require('remark');
    const remarkHtml = require('remark-html');
    async function markdownToHtml(markdown) {
    const result = await remark().use(remarkHtml).process(markdown);
    return result.toString();
    }
    function getPostSlugs() {
    return fs.readdirSync(postsDirectory);
    }
    function getPostBySlug(slug, fields = []) {
    const realSlug = slug.replace(/\.mdx$/, '');
    const fullPath = path.join(postsDirectory, `${realSlug}.mdx`);
    const fileContents = fs.readFileSync(fullPath, 'utf8');
    // parse the markdown file
    const { data, content } = matter(fileContents);
    const items = {};
    // Ensure only the minimal needed data is exposed
    fields.forEach((field) => {
    if (field === 'slug') {
    items[field] = realSlug;
    }
    if (field === 'content') {
    items[field] = content;
    }
    if (data[field]) {
    items[field] = data[field];
    }
    });
    return items;
    }
    function getAllPosts(fields = []) {
    const slugs = getPostSlugs();
    const posts = slugs
    .map((slug) => getPostBySlug(slug, fields))
    // sort posts by date in descending order
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
    return posts;
    }
    (async () => {
    if (process.env.NODE_ENV === 'development') {
    return;
    }
    const baseUrl = 'https://YOURDOMAINHERE.com';
    const date = new Date();
    const author = {
    name: 'Some Person',
    email: 'hey@YOURDOMAINHERE.com',
    link: 'https://twitter.com/sometwitterhandlethattotallyexists',
    };
    const feed = new Feed({
    title: `My Awesome site`,
    description: 'This is my Cool RSS Feed',
    id: baseUrl,
    link: baseUrl,
    language: 'en',
    image: `${baseUrl}/images/logo.svg`,
    favicon: `${baseUrl}/favicon.ico`,
    copyright: `All rights reserved ${date.getFullYear()}, Your Name`,
    updated: date,
    generator: 'Next.js',
    feedLinks: {
    rss2: `${baseUrl}/rss.xml`,
    json: `${baseUrl}/feed.json`,
    atom: `${baseUrl}/atom.xml`,
    },
    author,
    });
    const posts = getAllPosts(['title', 'date', 'path', 'excerpt', 'content']);
    const postPromises = posts.map(async (post) => {
    const url = `${baseUrl}${post.path}`;
    //convert the markdown to HTML that we'll pass to the RSS feed
    const content = await markdownToHtml(post.content);
    feed.addItem({
    title: post.title,
    id: url,
    link: url,
    description: post.excerpt,
    content: content,
    author: [author],
    contributor: [author],
    date: new Date(post.date),
    });
    });
    await Promise.all(postPromises);
    fs.writeFileSync('./public/rss.xml', feed.rss2());
    fs.writeFileSync('./public/atom.xml', feed.atom1());
    fs.writeFileSync('./public/feed.json', feed.json1());
    })();
  • Add the script to the next.config.js file, similar to what we did for the sitemap:

    // next.config.js
    module.exports = {
    // modify your webpack export if you've already got one
    webpack: (config, { isServer }) => {
    if (isServer) {
    //if you followed the sitemap instructions, you only need the next line
    require('./scripts/generateRss');
    }
    return config;
    },
    };
  • Finally, add public/rss.xml, public/atom.xml, and public/feed.json to your .gitignore

(Shameless plug for the useEffect book I wrote below)

Tired of infinite re-renders when using useEffect?

A few years ago when I worked at Atlassian, a useEffect bug I wrote took down part of Jira for roughly one hour.

Knowing thousands of customers can't work because of a bug you wrote is a terrible feeling. To save others from making the same mistakes, I wrote a single resource that answers all of your questions about useEffect, after teaching it here on my blog for the last couple of years. It's packed with examples to get you confident writing and refactoring your useEffect code.

In a single afternoon, you'll learn how to fetch data with useEffect, how to use the dependency array, even how to prevent infinite re-renders with useCallback.

Master useEffect, in a single afternoon.

useEffect By Example's book cover