Rewriting my blog with Next.js

How I moved this website from Gatsby to Next.js

2022-11-1513 min read

I have written a post about my blog's stack and why I don't plan on rewriting it with a different framework. Turns out, this was a lie, and I rewrote it.

TL;DR
  • Gatsby is excellent for static sites. I don't like it for dynamic sites, and its workarounds are not ideal.
  • Next.js is excellent too, but recreating the same functionality (Markdown pipeline, RSS, Image optimizations) as Gatsby is a pain.
  • Next.js has the market share, and the backing of the React core team, so it should be future-proof.
  • The current stack is Next.js, Tailwind, Floating-UI, MDX, Cloudinary, and Netlify.
  • I've introduced some fun new features, like better snippets, static tweets, and more.
  • Overall the rewrite was a success, I'm happy with the result but it's far from perfect.

Moving on from Gatsby

I've been using Gatsby since v0, and it's perfect for my use case. That said I had a few reasons for moving on.

  1. I mostly work with Next.js. My only Gatsby project is my blog and I don't like the context-switching.
  2. There's nothing that interests me in the latest Gatsby releases. For example in Gatsby 5, the two big changes are Partial Hydration and the Slices API.
  3. I just don't like the GraphQL layer and its plugin ecosystem.

I can't stress enough the last point. I wanted to use MDX in my Gatsby blog, and I was debugging some GraphQL weirdness that pushed me to rewrite it all with Next.js.

If I didn't like tweaking, creating silly pages, and using it as a pet project, I would have just stuck with Gatsby. But I need a playground to experiment, so I decided to rewrite my blog with Next.js.

Don't get me wrong. I'm glad I used Gatsby. It introduced me to GraphQL and allowed me to ship a very fast website to showcase my work, with little configuration. I don't want to bash it, just state my reasons for the rewrite.

Rewriting with Next.js

I wanted to pick a framework that supports SSR out of the box. I was torn between Remix & Next.js but decided to go for the more "mature" Next.js. Had I seen the Shopify acquisition news earlier, I might have picked Remix.

I was also a bit hyped by the Next.js 13 release and wanted to try out the new features. My enthusiasm didn't last long, as I realized it was a paper release, and the new features were still in beta or experimental. Anyhow, I decided to stick with it, use the /pages folder for now and see how it goes.

Next.js on Netlify

I have various projects and they all live on Netlify. Even if Vercel is a better fit for Next.js, I'm not going to change hosting providers. I'm also a bit stubborn, and I don't like vendor lock-in. If Next.js starts to become problematic to deploy elsewhere, I'll move to Remix.

So far the experience has been smooth. CSP headers were not applying correctly, but I fixed it by adding a netlify.toml file. I also cached my fonts and static assets, something Vercel supposedly does out of the box.

Here's my config so far:

netlify.toml
[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "DENY"
    X-XSS-Protection = "1; mode=block"
    Content-Security-Policy = '''
    default-src 'self';
    script-src 'self' 'unsafe-eval' 'unsafe-inline';
    child-src codesandbox.io;
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data: *.cloudinary.com pbs.twimg.com i.scdn.co;
    media-src 'none';
    connect-src *;
    font-src 'self';'''
    Referrer-Policy = "no-referrer"
    X-Content-Type-Options = "nosniff"
    X-DNS-Prefetch-Control = "off"
    Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload"
    Permissions-Policy = "camera=(), microphone=(), geolocation=()"
 
[[headers]]
  for = "/fonts/*"
    [headers.values]
      cache-control = 'public, max-age=31536000, immutable'
 
[[headers]]
  for = "/_next/static/*"
    [headers.values]
      cache-control = 'public, max-age=31536000, immutable'

Refactoring the code

This list is not exhaustive, but it's a good summary of the changes I made. The snippets I'll post are meant to provide some general idea, and might not show the whole implementation. I'll try to keep it updated as I make more changes.

1. Updating pages and components

I decided to start a new project from scratch and add TypeScript. Thankfully, I had abstracted most of the Gatsby-related stuff, so it was painless moving everything to their new folders.

React
src/pages/blog.js
export default function GatsbyBlogPage() {
  const postsByYear = useGroupedPosts();
 
  return (
    <Layout>
      <Container>
        <Cover
          title="Blog"
          text="Writing about coding, life, productivity & more"
        />
        <PostGrid postsByYear={postsByYear} />
      </Container>
    </Layout>
  );
}
 
export function Head() {
  return (
    <MetaTags
      title="Blog"
      path="/blog"
      description="Writing about coding, life, productivity & more"
    />
  );
}
React
src/pages/blog.tsx
export default function NextBlogPage({
  postsByYear,
}: {
  postsByYear: GroupedPosts;
}) {
  return (
    <>
      <MetaTags
        title="Blog"
        path="/blog"
        description="Writing about coding, life, productivity & more"
      />
      <Layout>
        <Container>
          <Cover
            title="Blog"
            text="Writing about coding, life, productivity & more"
          />
          <PostGrid postsByYear={postsByYear} />
        </Container>
      </Layout>
    </>
  );
}
 
export async function getStaticProps() {
  const postsByYear = getAllGroupedPosts();
  return {props: {postsByYear}};
}

2. Updating navigation

My first issue was replacing the very clever <GatsbyLink />. Unfortunately, Next.js needs some help with highlighting the active navigation link. I also had to update the prefetch functionality, so that linked pages were fetched only on hover. Otherwise, it was a simple change.

JavaScript
GatsbyLink.jsx
import React from 'react';
import {Link as GatsbyLink} from 'gatsby';
 
export function Link({children, to, ...props}) {
  return (
    <GatsbyLink to={to} {...props}>
      {children}
    </GatsbyLink>
  );
}
React
NextLink.tsx
import NextLink, {LinkProps} from 'next/link';
import {useRouter} from 'next/router';
 
type ActiveLinkProps = LinkProps & {
  activeClassName?: string;
  className?: string;
};
 
export function Link({
  href,
  children,
  className: targetClassName = '',
  activeClassName,
  prefetch = false,
  ...props
}: React.PropsWithChildren<ActiveLinkProps>) {
  const {asPath} = useRouter();
  const className =
    targetClassName +
    (asPath === href && activeClassName ? ' ' + activeClassName : '');
 
  return (
    <NextLink href={href} {...props} className={className} prefetch={prefetch}>
      {children}
    </NextLink>
  );
}

3. Nit changes

There were some smaller changes, like font loading, analytics configuration, and CSS loading. In short, whatever goes in gatsby-browser.js in Gatsby, goes in pages/_app.js in Next.js.

React
pages/_app.js
import '@/styles/global.css';
 
import {Inter} from '@next/font/google';
import type {AppProps} from 'next/app';
 
import {Analytics} from '@/components/Analytics';
 
const interVariable = Inter({subsets: ['latin'], display: 'swap'});
 
export default function App({Component, pageProps: {...pageProps}}: AppProps) {
  return (
    <>
      <Analytics />
      <div className={interVariable.className}>
        <Component {...pageProps} />
      </div>
    </>
  );
}

4. Dropping GatsbyImage, and using Cloudinary

Boy, oh boy. <GatsbyImage /> is awesome. I really didn't have to think about images in my Gatsby blog at all. Vercel pretty much vendor locks <NextImage />, and I deploy on Netlify, so I decided to use Cloudinary and call it a day. I know Netlify does its best with its own Next Runtime, but I wanted to trust a dedicated service for optimizing my images. Here's what I'm using:

React
CloudinaryImage.tsx
import Image from 'next/image';
 
import {CLOUDINARY_URL} from '@/links';
 
type CloudinaryImageProps = {
  src: string;
  alt: string;
  width: number;
  height: number;
  title?: string;
  className?: string;
  blurDataURL?: string;
  quality?: number;
};
 
type CloudinaryLoaderProps = Pick<
  CloudinaryImageProps,
  'src' | 'width' | 'height'
>;
 
function cloudinaryLoader({src, width, quality = 90}: CloudinaryLoaderProps) {
  return `${CLOUDINARY_URL}/w_${width},q_${quality},f_webp${src}`;
}
 
export function CloudinaryImage({
  src,
  alt,
  width,
  height,
  title,
  className,
  blurDataURL,
  quality,
}: CloudinaryImageProps) {
  return (
    <Image
      quality={quality}
      src={src}
      loader={cloudinaryLoader}
      alt={alt}
      title={title}
      width={width}
      height={height}
      className={className}
      {...(blurDataURL ? {placeholder: 'blur', blurDataURL: blurDataURL} : {})}
    />
  );
}

As for the blurDataURL, I created a function to generate them on demand

TypeScript
getBase64Image.ts
export async function getBase64Image(imageId: string): Promise<string> {
  const response = await fetch(
    `${CLOUDINARY_URL}/w_100/e_blur:1000,q_auto,f_webp${imageId}`
  );
  const buffer = await response.arrayBuffer();
  const data = Buffer.from(buffer).toString('base64');
  return `data:image/webp;base64,${data}`;
}

..and include them in the MDX files.

blog/gatsby-to-next/index.mdx
---
title: 'Migrating from Gatsby to Next.js'
date: '2022-11-10'
description: 'Documenting website rewrite with Next.js'
category: 'React'
featuredImage: '/v1667896248/blog-images/covers/gatsby-to-next.jpg'
blurHash: 'data:image/webp;base64,UklGRi4BAABXRUJQVlA4ICIBAACQCgCdASpkAEMAPrFEmEopKqIhtBuacVAWCWctgApEhfgL6cVs+EP+L7eCBMs6WxuBdNhnrhgZBc3o3T07pyMmL/33nE0QwO5CRL7Rcmu4dPcZyTC4MBUkUest3AAA/vCwN7ITIh1amqRK9uZ9e8L+JX4epgvVOoHWyh61Nr2+j3iJ1n5+tTV1e8qSP1QjANudOvbZEuBwQa9ipePIbKgS4Q5u5hroxOh4PorEJQgLU0HpsMc8BorpBdCX9VTbR4lYvZ9cZ34FW5j9pTYfkDICNcGYDODTMFeWGroq5PG5p6X6BMdLTOLEgfKWZYzwyjObVdLf7A0FeBoPihEMEILwKlUghr/eFH8vKHeKgdhCwhCLhB1qGhoCBzA7e8c4HyNIAA=='
---
 
I have [written a post](/blog/state-of-blog) about my blog's stack and why I don't plan on rewriting it with a different framework. Turns out, this was a lie, and I rewrote it.

Finally, I can use the <CloudinaryImage /> component anywhere in my app, including MDX files.

React
components/Post.tsx
export function Post({post}: {post: PostType}) {
  return (
    <Link
      className="overflow-hidden h-full z-0 grid sm:grid-cols-1 ring-gray-100 ring-offset-8 hover:ring-4 rounded-lg group"
      href={`/blog/${post.slug}`}
      key={post.slug}
      aria-label={`Read the article '${post.frontmatter.title}'`}
    >
      <div>
        <div className="flex-shrink-0 relative rounded-lg overflow-hidden">
          <CloudinaryImage
            alt={`Cover image of '${post.frontmatter.title}'`}
            src={post.frontmatter.featuredImage}
            width={480}
            height={320}
            className="max-h-full w-full rounded-lg overflow-hidden brightness-75 object-cover"
            blurDataURL={post.frontmatter.blurHash}
          />
        </div>
        {/* ... */}
    </Link>
  );
}

Could I do better? Probably. For now, it works fine and I can focus on other things. Most importantly I can focus on writing content, and if I feel inspired, build something like Kent C. Dodds' getImageBuilder.

5. Replacing Gatsby's Markdown plugins, and introducing MDX

Here's the juicy part. I wanted to create some custom components in my markdown files. Essentially I had to replicate the functionality of these plugins:

JavaScript
gatsby-config.js
// This is not my full config, just the relevant parts
module.exports = {
  plugins: [
    `gatsby-transformer-json`,
    `gatsby-plugin-sharp`,
    `gatsby-transformer-sharp`,
    `gatsby-plugin-image`,
    `gatsby-plugin-twitter`,
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        path: `${__dirname}/src/content/`,
        name: 'data',
      },
    },
    {
      resolve: `gatsby-transformer-remark`,
      options: {
        plugins: [
          {
            resolve: `gatsby-remark-autolink-headers`,
            options: {
              offsetY: `100`,
              icon: `<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill='#2A506F' 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>`,
              maintainCase: true,
            },
          },
          {
            resolve: `gatsby-remark-vscode`,
            options: {
              theme: `GitHub Dark`,
              extensions: [
                path.resolve(__dirname, './github-vscode-theme.zip'),
              ],
            },
          },
          `gatsby-remark-copy-linked-files`,
          `gatsby-remark-external-links`,
          `gatsby-remark-responsive-iframe`,
          {
            resolve: `gatsby-remark-images`,
            options: {
              maxWidth: 900,
              quality: 100,
              withWebp: true,
            },
          },
        ],
      },
    },
    {
      resolve: `gatsby-plugin-feed`,
      options: {},
    },
  ],
};

Alright, first I created a /lib/posts.ts file with all the relevant functions to get the posts. I'll spare you most of the implementation details. Essentially I'm reading from the file system and parsing the frontmatter and markdown body.

TypeScript
lib/posts.ts
import fs from 'fs';
import matter from 'gray-matter';
import {join} from 'path';
 
import {PaginatedPreviewPost, Post, PostWithMdx} from '@/types/types';
 
import {mdxToHtml} from './mdxToHtml';
 
const postsDirectory = join(process.cwd(), 'content/blog');
 
export function getPostMetadataBySlug(slug: string): Post {}
 
export async function getFullPostBySlug(slug: string): PostWithMdx {
  const fullPath = join(postsDirectory, slug, 'index.md');
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const {data: frontmatter, content} = matter(fileContents);
 
  const mdxContent = await mdxToHtml(content);
 
  return {slug, frontmatter, content: mdxContent};
}
 
export function getAllPosts(): Array<Post> {}
 
export function getLatestPosts(numberOfPosts = 4): Array<Post> {}
 
export function getAllGroupedPosts(): Record<number, Array<Post>> {}
 
export function getPrevAndNextPosts(slug: string) {}

As for the MDX transformations, I'm using next-mdx-remote with a bunch of rehype plugins. Pretty much what Gatsby does under the hood.

TypeScript
lib/mdxToHtml.ts
import {serialize} from 'next-mdx-remote/serialize';
import readingTime from 'reading-time';
import {rehypeAccessibleEmojis} from 'rehype-accessible-emojis';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import remarkGfm from 'remark-gfm';
 
import {MdxContent} from '@/types/types';
 
import {shikiOptions} from './shiki';
 
export async function mdxToHtml(source: string): Promise<MdxContent> {
  const mdxSource = await serialize(source, {
    mdxOptions: {
      format: 'mdx',
      remarkPlugins: [remarkGfm],
      rehypePlugins: [
        rehypeSlug,
        [rehypeAutolinkHeadings, {properties: {className: ['anchor']}}],
        [rehypePrettyCode, shikiOptions],
        rehypeAccessibleEmojis,
      ],
    },
  });
 
  return {
    html: mdxSource,
    wordCount: source.split(/\s+/gu).length,
    readingTime: readingTime(source).text,
  };
}
I'm using Shiki for the syntax highlighting. I tried both Prism.js & Highlight.js and neither come close to it. I can use any VSCode theme, just like I was used to with gatsby-remark-vscode. Currently, I'm using the "Tokyo Night Storm" theme.
That said, I have heavily patched the rehype-pretty-code plugin. Since I'm not doing client-side highlighting, my bundle is a bit bloated, so I had fix some stuff.

Finally, consuming the data on the pages.

React
pages/blog/[slug].tsx
import {PostCover} from '@/components/blog/PostCover';
import {PostFooter} from '@/components/blog/PostFooter';
import {Container} from '@/components/Container';
import {Layout} from '@/components/Layout';
import {MDXRenderer} from '@/components/mdx/Mdx';
import {MetaTags} from '@/components/MetaTags';
import {getAllPosts, getFullPostBySlug, getPrevAndNextPosts} from '@/lib/posts';
import type {PaginatedPreviewPost, PostWithMdx} from '@/types/types';
 
export default function BlogPage({
  post,
  prevPost,
  nextPost,
}: {
  post: PostWithMdx;
  prevPost: PaginatedPreviewPost;
  nextPost: PaginatedPreviewPost;
}) {
  return (
    <>
      <MetaTags
        title={post.frontmatter.title}
        path={post.slug}
        description={post.frontmatter.description}
      />
      <Layout>
        <Container>
          <PostCover post={post} />
        </Container>
        <article className="prose max-w-3xl mx-auto px-6 pt-6">
          <MDXRenderer {...post.content.html} />
        </div>
        <div className="max-w-3xl mx-auto px-6">
          <hr className="my-12 text-zinc-100" />
          <PostFooter prevPost={prevPost} nextPost={nextPost} />
        </div>
      </Layout>
    </>
  );
}
 
export async function getStaticPaths() {
  const paths = getAllPosts();
  return {
    paths: paths.map(({slug}) => ({params: {slug}})),
    fallback: 'blocking',
  };
}
 
export async function getStaticProps({params}: {params: {slug: string}}) {
  const post = await getFullPostBySlug(params.slug);
 
  if (!post) {
    return {notFound: true};
  }
 
  const {prevPost, nextPost} = getPrevAndNextPosts(post.slug);
 
  return {
    props: {
      post: {
        ...post,
        frontmatter: {
          ...post.frontmatter,
          featuredImage: '',
          blurHash: '',
        },
      },
      prevPost,
      nextPost,
    },
  };
}

6. Building custom components

The process is straightforward. Write a React component..

React
components/mdx/BlockQuotes.tsx
export function InfoBox({children}: {children: React.ReactNode}) {
  return (
    <blockquote className="border border-indigo-300 bg-indigo-50 relative text-md">
      <InformationCircleIcon className="h-8 w-8 absolute -top-3 -left-3 text-white bg-indigo-300 rounded-full" />
      {children}
    </blockquote>
  );
}

...and add it in the MDXRenderer's list of custom components.

React
components/mdx/Mdx.tsx
import type {MDXRemoteSerializeResult} from 'next-mdx-remote';
import {MDXRemote} from 'next-mdx-remote';
 
import {InfoBox} from './BlockQuotes';
 
export const components = {InfoBox};
 
export function MDXRenderer(props: MDXRemoteSerializeResult) {
  return <MDXRemote {...props} components={components} />;
}

Then in my MDX file, I can call it as a normal React component. This makes it very easy to build fancy stuff like Charts, Code Blocks, DataTables, or some silly stuff I'm planning next.

Dummy.mdx
<InfoBox>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin ultrices faucibus massa, vitae placerat leo lacinia non. Integer malesuada velit in magna luctus sodales sed eu justo.</InfoBox>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin ultrices faucibus massa, vitae placerat leo lacinia non. Integer malesuada velit in magna luctus sodales sed eu justo.

7. RSS

My RSS implementation makes me sad. At this point, I contemplated if writing my blog in Next.js makes any sense. I'm using this approach. This will be improved in the future.

Conclusion

Was it worth it?

My previous stack wasn't broken, so it didn't need fixing. There isn't any wow moment, I just had to scratch that itch. I'm happy with the result, even though it feels very hacky at various places, but I'm sure I'll improve it moving forward.

If anything, I'm particularly happy with the bundle size decrease, as well as my fancy new code blocks.

I shouldn't be hard on Next.js, as I'm building a blog and not a complex app. If anything I make things harder for myself by not using a CMS.


Resources