Skip to content

Migrate to NextJS

Published on
Migrate to NextJS

While still learning JavaScript and TypeScript and working through some issues with my blog, I decided to migrate from Gatsby to Next.js.

The blog is now hosted on Vercel, built using Next.js and Tailwind CSS.

I had previously encountered numerous challenges with the Gatsby version.

Thanks to timlrx’s starter, I no longer have to manage data fetching and typography from scratch as I did with Gatsby. (In the previous version, I struggled with dark mode, spacing in markdown lists, and poor styling with prism.js.)

Modifications

In addition to carrying over the design elements from my previous version, I made several other modifications to the starter:

  • Dark Mode: Updated the dark mode theme using GitHub’s “dark dimmed” style, setting the background to #22272E and the text color to #ADBAC7. I also adjusted the hover state for cards and buttons to use bg-opacity-10.
  • Images: I’m now using Next.js Image components for the PostCard and post covers, which helps optimize and reduce the size of the original images. However, since the component requires explicit width and height, it’s a bit challenging to use within standard Markdown files. I considered switching to MDX, but I prefer keeping my Markdown files “pure.” I’ll elaborate on how I solved this later.
  • Minor Modifications
    • Added a greeting with gradient text and Twemoji (inspired by Leo).
    • Implemented a two-column grid for post cards on larger screens.
    • Archive Page Layout: Rebuilt the archive page using Flowbite, a fantastic Tailwind-based component library. This allowed me to remove daisyUI, which I used previously.

During the migration, I ran into several issues while upgrading dependencies. For instance, after upgrading to React 18 and Next.js 13, everything worked fine in development mode, but certain buttons failed to function in the production build.

  • React 18 “Hydration failed because the initial UI does not match what was rendered on the server.”
  • Package subpath ’./jsx-runtime.js’ is not defined by “exports”

Migrate To TypeScript

As said in TypeScript Documents:

Most programming languages would throw an error when these sorts of errors occur, some would do so during compilation — before any code is running. When writing small programs, such quirks are annoying but manageable; when writing applications with hundreds or thousands of lines of code, these constant surprises are a serious problem.

I believed that migrating to TypeScript would help resolve these issues. At the very least, it would save me from endless hours of searching Google, Stack Overflow, and GitHub trying to figure out why a seemingly working dev build breaks in production—a frustrating waste of energy when the root cause is unclear.

References:

TypeScript has many features to learn. For now, I’ve transitioned from JavaScript using a less-strict configuration just to get everything working first.

It feels like moving from Python to Java! 😂

const timeMap: Map<string, Map<string, Array<FrontMatter>>> = new Map()
for (const post of posts) {
  if (post.date !== null) {
    const year: string = new Date(post.date).getFullYear().toString()
    const month: string = new Date(post.date).toDateString().split(' ')[1]
    if (!timeMap.has(year)) {
      timeMap.set(year, new Map())
    }
    if (!timeMap.get(year).has(month)) {
      timeMap.get(year).set(month, [])
    }
    timeMap.get(year).get(month).push(post)
  }
}

Many files from the original starter still lack proper type definitions, so some bugs might persist. I plan to finalize the React and Next.js upgrades once the TypeScript migration is complete.

After migrating to TypeScript, I attempted the React and Next.js upgrades again, but client-side bugs persisted. I eventually discovered the culprit: the starter was using Preact for production builds. Since Preact didn’t yet support the new React 18 hooks through shims at the time, I removed it, and the upgrade was finally successful.

Using MarkdownX

Even without using the .mdx extension, we can leverage MDX features within .md files. However, editing JSX code inside a Markdown file is somewhat annoying due to the lack of auto-completion.

The starter makes it easy to use JSX directly within Markdown files. For example:

source code:

<div className="grid grid-cols-2 gap-3">
  <div>![](...)</div>
  <div>![](...)</div>
</div>

One issue I faced was that my previously embedded Spotify and Apple Music iframes stopped working.

If I directly copy the code Apple offers, it’ll show:

Error: The style prop expects a mapping from style properties to values, not a string. For example, style={{marginRight: spacing + 'em'}} when using JSX.

We need to follow JSX syntax for the style prop (using objects instead of strings). Additionally, to make these work, I had to adjust the Content Security Policy (CSP).

In fact, we can design custom components using third-party APIs and JSX and use them directly in MDX. However, overusing custom components in Markdown files can make them harder to migrate to other platforms in the future.

Custom Components

<TOCInline toc={props.toc} asDisclosure />

NextJS Images1

The main challenge was using remote images hosted on Alibaba Cloud (Aliyun) OSS. Next.js Image components require width and height to be known upfront. To replace standard img tags with NextImage, I needed to fetch image metadata during the build process. I used image-size to extract dimensions and plaiceholder to generate base64 blur placeholders (following the approach in this post).

I used unist-util-visit to traverse the syntax tree, locate img nodes, and inject the necessary props. In Markdown, images are typically represented as an img node nested within a p (paragraph) node. The visit function takes the tree, a filter function, and a callback to modify the matching nodes.2

Note: Use async, await, Promise.

import { visit } from 'unist-util-visit'
import imageSize from 'image-size'
import { ISizeCalculationResult } from 'image-size/dist/types/interface'

const remarkImgToJsx = () => {
  return async function transformer(tree: Node): Promise<Node> {
    const images: UnistImageNode[] = []

    visit(
      tree,
      (node: UnistNodeType) =>
        node.type === 'paragraph' && node.children.some((n) => n.type === 'image'),
      (node: UnistNodeType) => {
        const imageNode = node.children.find((n) => n.type === 'image') as UnistImageNode
        images.push(imageNode)
        // Change node types from p to div to avoid nesting error
        node.type = 'div'
        node.children = [imageNode]
      }
    )

    for (const image of images) {
      await addProps(image)
    }
  }
}

The addProps function replaces standard image attributes with Next.js Image props, including the blur placeholder generated by plaiceholder.

async function addProps(imageNode: UnistImageNode): Promise<void> {
  let res: ISizeCalculationResult
  let blur64: string
  if (imageNode.url.startsWith('http') && !imageNode.url.endsWith('svg')) {
    const imageRes = await fetch(imageNode.url)
    const arrayBuffer = await imageRes.arrayBuffer()
    const buffer = Buffer.from(arrayBuffer)

    res = await imageSize(buffer)
    blur64 = (await getPlaiceholder(buffer)).base64
    ;(imageNode.type = 'mdxJsxFlowElement'),
      (imageNode.name = 'Image'),
      (imageNode.attributes = [
        { type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
        { type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
        { type: 'mdxJsxAttribute', name: 'width', value: res.width },
        { type: 'mdxJsxAttribute', name: 'height', value: res.height },
        { type: 'mdxJsxAttribute', name: 'quality', value: 100 },
        { type: 'mdxJsxAttribute', name: 'placeholder', value: 'blur-sm' },
        { type: 'mdxJsxAttribute', name: 'blurDataURL', value: blur64 },
      ])
  }
}

As a result, every original image tag is transformed from:

<p><img src="..." /></p>

to Next.js Image components, enabling on-demand resizing and optimization.

Note: Fetching remote images during the build means the server must download each image first, which significantly increases build times.

While the production site performance was great, the local development experience became painfully slow due to the constant image fetching and processing.

Later, I discovered that my image host (OSS) can provide width and height directly via its API, which significantly speeds up both development and build times.

For example, if I call an image with URL ?x-oss-process=image/info, I can get:

{
  "FileSize": { "value": "4152561" },
  "Format": { "value": "jpg" },
  "FrameCount": { "value": "1" },
  "ImageHeight": { "value": "3712" },
  "ImageWidth": { "value": "5568" },
  "ResolutionUnit": { "value": "2" },
  "XResolution": { "value": "72/1" },
  "YResolution": { "value": "72/1" }
}

Although the OSS hoster handles image processing, it doesn’t generate base64 blur strings like plaiceholder. To solve this, I used a static 1x1 pixel base64 string (from png-pixel.com, as suggested in the Next.js docs) as a universal placeholder.

const res = await fetch(post.image + '?x-oss-process=image/info')
const json = await res.json()

const height = json.ImageHeight.value
const width = json.ImageWidth.value
post.imageMetadata = {
  height: height,
  width: width,
  blurDataURL: `data:image/png;base64,${siteMetadata.blur64}`,
}
blur64:   'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=',

Domain Blocked

In China, the vercel.app domain is blocked. However, I noticed other Vercel-hosted sites working perfectly. After some testing, I realized that while many Vercel sites remain accessible via custom domains, mine was not. I spent hours troubleshooting, even reaching out to another developer to see if I missed any configuration. Eventually, I discovered the depressing truth: my own domain, zzhgo.com, was specifically blocked by the GFW. As a result, I had to migrate to a new domain: atksoto.com.

Footnotes

  1. https://nextjs.org/docs/basic-features/image-optimization

  2. Content as structured data https://unifiedjs.com