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-remark-autolink-headers
- 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# oryarn 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)
Gatsby Link vs Next.js Link
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.jsimport 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 }) => (<preclassName={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 defaultcode
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
andremark-autolink-headings
:yarn add remark-slug remark-autolink-headingsThen add them to your article template:
// `[slug].tsx`// ...imports etc up here//svgIcon shamelessly stolen from gatsby-remark-autolink-headersconst 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 pluginsmdxOptions: {remarkPlugins: [require('remark-slug'),[require('remark-autolink-headings'),{behavior: 'prepend',linkProperties: {'aria-label': `permalink`, // this could be more usefulclass: '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:
- Use useEffect to scroll to the linked id
- 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 calledgenerateSitemap.js
Paste the following, and change the domain name as needed:
// scripts/generateSitemap.jsconst 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 pathconst 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-syncfs.writeFileSync('public/sitemap.xml', formatted);})();Then add the following to your next.config.js:
// next.config.jsmodule.exports = {// modify your webpack export if you've already got onewebpack: (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 calledgenerateRss.js
Install the following packages (if you're not using markdown files, all you need is feed):
yarn install feed gray-matter remark remark-htmlPaste 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 fileconst { data, content } = matter(fileContents);const items = {};// Ensure only the minimal needed data is exposedfields.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 feedconst 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.jsmodule.exports = {// modify your webpack export if you've already got onewebpack: (config, { isServer }) => {if (isServer) {//if you followed the sitemap instructions, you only need the next linerequire('./scripts/generateRss');}return config;},};Finally, add
public/rss.xml
,public/atom.xml
, andpublic/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.