· 15 min read

Rewriting My Entire Blog with Next.js

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Introduction

I have been blogging on and off since around 2015. Starting with Pixnet and Logdown, I eventually tweaked styles on Hexo, moved over to Medium, and in 2019, when Gatsby was all the rage, I built a site with it just to play around. Just like that, I settled on Gatsby for more than three years.

I still want to reiterate: if you want to start blogging, just pick whatever tool feels handy. The most important thing is the actual writing. I’ve seen far too many software engineers write posts titled “How I Built My Blog with XYZ,” only to never update it again, or spend hours building a fancy site that contains nothing but “Hello World” or “Test.” I am very glad I stuck with the habit of writing and sharing. While the traffic isn’t massive, it has built up a decent and loyal readership over time.

Why Move Away from Gatsby?

Back to the topic at hand. Here are a few pain points I ran into:

  • Static site generation: Every time I finished writing a post, the entire site had to be rebuilt from scratch. (Even though CI handles the build, it’s still cumbersome.)
  • Categorization: Previously, categories were handled simply by matching metadata. Over time, it became easy to forget existing tags and too tedious to search for them, resulting in a disorganized mess.
  • Article volume: Over the years, I’ve accumulated more than 140 articles. It was time to find a better way to store and manage them.
  • Fun: Next.js has evolved significantly in recent years and supports a ton of features out of the box. Plus, it deploys seamlessly to Vercel, which is great for someone lazy like me who doesn’t want to manage servers.

Although Gatsby is feature-rich, it ultimately outputs static files, and all articles must be managed locally. While Gatsby does support content sources other than the local filesystem, setting them up is somewhat tedious, and your data must conform to Gatsby’s required schema. Whenever you need customized features, you have to hunt for plugins or build them yourself. These costs add up to substantial overhead.

For these reasons, I ultimately chose Next.js for its higher customizability.

Requirements

Here is a summary of my requirements for the blog:

  • Markdown to HTML conversion: An absolute must. All my articles are written in Markdown, and I use math syntax, footnotes, etc.
  • Code syntax highlighting.
  • Database: To conveniently store and manage articles, preferably with granular control so I can define tables and create indexes myself.
  • RSS support: I care deeply about RSS support. The blog can be minimal, but RSS is non-negotiable. Many readers rely on RSS feeds for new post notifications.
  • Image and video uploads: Article images are currently hosted on a CDN, so I wanted a simple editor that supports direct uploads.
  • Multi-language support (i18n): For translating certain posts when sharing with international communities.
  • Fully customizable pages.

Tech Stack

  • Frontend & Backend: Next.js (implementation details below).
  • Multi-language support: Implemented via Next.js’s built-in i18n routing; translation retrieval details will be covered later.
  • Markdown and code syntax highlighting: Powered by remark and shiki.
  • Database: PostgreSQL, hosted on Google Cloud SQL (databases are pricier than I thought 😱).
  • RSS Feed: Periodically generated using Cloud Functions and Cloud Scheduler.
  • Static asset storage: Amazon S3, paired with CloudFront as the CDN.
  • Deployment: GitHub integration via Vercel; push a new commit and it automatically deploys.

Reading this, you might wonder: why store static files on AWS while using Google Cloud for the rest?

Having used AWS RDS and Lambda before, I felt Google Cloud’s UI and configurations were more developer-friendly, so I moved there. However, since my static assets had been on S3 since the blog’s inception, I just kept them there. If I find some free time down the road, I might do a massive static file migration.

Implementation

Migrating Articles to the Database (Cloud SQL)

Since previous articles were individual Markdown files, the first step was migrating their contents into the database. I designed two tables: posts and categories. To support i18n, columns like title and excerpt use the json type. While this makes querying slightly more complex, it makes adding translations in other languages much easier down the line. Next, I wrote SQL scripts to import the articles into their respective columns. It’s worth noting that all schema migrations and database operations were saved as standalone .sql files, making it easy to roll back or recreate the database if anything goes wrong.

This might be common sense for seasoned engineers, but I’ve noticed many developers either rely blindly on framework-provided magic or run ad-hoc commands in the CLI, leaving them at a loss when changes are needed later.

Next.js

First, let’s talk about why Next.js. While major frontend frameworks have their own SSR equivalents, in my experience, Next.js remains the most feature-complete and well-integrated. You can start developing with practically zero configuration. Here are a few development details:

SSR (Server Side Rendering) vs. ISR (Incremental Static Regeneration)

Next.js offers three page rendering strategies:

  • Static: Generates pure static HTML at build time, using getStaticProps to pass in props.
  • ISR: Generates static pages for pre-defined paths at build time; if a requested path doesn’t exist yet, it falls back to SSR to render the page.
  • SSR: Renders the page on every request by running getServerSideProps, returning the rendered HTML, and hydrating JavaScript logic on the client side (attaching event listeners, etc.).

For my blog’s use case, the homepage and pages 2 and 3 are accessed frequently and need updating whenever a new post is published, so ISR was the perfect fit. Post content pages also use ISR: the latest 50 articles are statically generated at build time, while older articles are generated on-demand when requested. Pages like /contact are built as pure static pages.

In practice, static rendering is unbeatable in performance. Because SSR requires connecting to the database on the server, it inevitably takes longer.

i18n

Next.js includes built-in i18n routing, allowing you to configure redirects to different domains or sub-paths based on language. For example:

  • /zh/posts: sets the locale to zh
  • /en/posts: sets the locale to en

It also supports automatic language detection (via headers or navigator.languages). You can easily retrieve the current locale on both the server and client sides:

// Server side
const getServerSideProps = ({ locale }) => {
  // current locale
} 

// Client side
import { useRouter } from 'next/router'
const Component = () => {
  const { locale } = useRouter()
}

Typically, developers use libraries like react-intl or react-i18next to simplify i18n. But glancing through their docs made my head spin. For my needs, there are no third-party services involved, no complex failure scenarios to handle, and not enough translation keys to warrant dynamic loading.

So, I wrote a simple custom implementation:

import React, { createContext, useCallback, useContext } from "react";
import { en } from "./en";
import { ja } from "./ja";
import { zh } from "./zh";
type I18nData = {
  _i18n: {
    locales: string[];
    data: {
      [key: string]: {
        [key: string]: string;
      };
    };
  };
};
export default function createI18n() {
  const datas = {
    en,
    ja,
    zh
  };
  return {
    _i18n: {
      locales: ["en", "zh", "ja"],
      data: datas
    }
  };
}

const I18nContext = createContext<{
  locale: string;
  _i18n: I18nData["_i18n"];
}>(null);

export const I18nProvider: React.FC<{
  _i18n: I18nData["_i18n"];
  locale: string;
  children: React.ReactElement;
}> = ({ _i18n, locale, children }) => {
  return (
    <I18nContext.Provider value={{ _i18n, locale }}>
      {children}
    </I18nContext.Provider>
  );
};

export const useTrans = () => {
  const { _i18n, locale } = useContext(I18nContext);
  const t = useCallback(
    (key: string) => {
      const data = _i18n.data[locale] || _i18n.data[locale];
      if (data) {
        return data[key] || key;
      }
      return key;
    },
    [_i18n.data, locale]
  );
  return { t };
};

It’s rudimentary, but it gets the job done. After all, I am almost certainly the only person working on this blog, and I can fix issues as they arise. Embedding strings directly in the JS bundle increases the bundle size slightly, but considering the text files are small and the homepage and popular pages use ISR, moving strings to a CDN later won’t be difficult if needed. This works fine for now.

Image Optimization

Next.js provides next/image specifically for image loading and optimization. Without dedicated handling, you might serve the exact same image to all devices (mobile, desktop). This leads to a poor experience: large screens benefit from higher-resolution images, whereas delivering oversized images to smaller screens wastes bandwidth without noticeable visual improvement.

Another issue is that images without predefined dimensions take up zero height before loading, causing sudden layout shifts once loaded. When there are multiple images on a page, network latency makes this layout shift especially jarring.

Therefore, using next/image in Next.js is highly recommended. You must explicitly specify dimensions or aspect ratios. Additionally, by default, requests handled by next/image are routed through the server for optimization before being served.

For instance, given a CDN URL like https://cdn.kalan.dev/images/avatar.jpeg, the actual request becomes: /_next/image?url=${URL}&w=128&q=75. This means requests through next/image pass through the server first and are optimized before being sent to the client, rather than hitting the source URL directly.

Next.js image processing workflow

The upside is delivering optimized formats and dimensions tailored to the requesting device. The downside is increased server load. Since I deploy on Vercel, which provides a default monthly allowance for image optimization, I don’t have to worry too much. However, if you are self-hosting on your own servers, this is something to keep in mind.

You might wonder: could someone abuse this API to optimize external images? Next.js requires configuring permitted image domains; requests for images from unlisted domains will be rejected.

If you prefer not to handle image optimization on your own compute instances, you can configure a custom loader to define how Next.js handles images (such as offloading them to a third-party image CDN).

remark and shiki

To convert Markdown to HTML, I used the popular remark ecosystem. First, Markdown is parsed into an AST using unified, then transformed into HTML using various plugins. For code syntax highlighting, I opted for shiki, primarily because I love its theme definitions and language grammar handling.

During development, I ran into an issue where Shiki dynamically loads theme and syntax configuration files at runtime. When deployed to Vercel, the code runs in serverless functions, where fs.readFile couldn’t properly read files inside node_modules. The workaround was to manually copy the theme and grammar configuration files directly into the project repository:

const languagesPath = path.join(
  process.cwd(),
  'src',
  'utils',
  'shiki',
  'languages'
)

const langs = shiki.BUNDLED_LANGUAGES.map((lang) => ({
  ...lang,
  path: path.join(languagesPath, lang.path || "")
}));

export default async function convertMdToHTML() {
  const nordTheme = shiki.toShikiTheme(theme as any);
  const highlighter = await shiki.getHighlighter({ theme: nordTheme, langs });
  ...
}

Vercel Edge Functions

Next.js 13 introduced experimental Edge Functions, which integrate directly with Vercel’s Edge Functions without extra configuration.

Unlike traditional Serverless Functions, Edge Functions on Vercel run in an isolated V8 Engine environment. Because the runtime environment is lightweight, cold starts are significantly faster, and functions can be distributed to global edge nodes closest to the user, reducing API response times.

By taking advantage of this small runtime, Edge Functions can have faster cold boots and higher scalability than Serverless Functions.

This feature is brilliant. Following the official example, I deployed an API that dynamically generates OG images based on post titles, styled entirely with HTML. If an article doesn’t have a dedicated OG image, it falls back to this API. There are a few limitations: because it runs inside a restricted VM environment, you cannot perform direct database operations, and certain HTTP capabilities may be constrained.

Serverless Functions

By default, when deploying Next.js to Vercel, non-static pages that utilize SSR or ISR are automatically packaged into Serverless Functions.

In other words, a Next.js app deployed to Vercel does not run on a single monolithic server; it is split into multiple Serverless Functions, which are subject to cold start latency.

Illustration of Next.js deployed on Vercel

A few practical details to keep in mind:

  • Unless you can tolerate data inconsistency, avoid using global variables for in-memory caching, as that memory cannot be shared across different function instances.
  • Understand Vercel’s limits regarding requests and responses.
  • My application establishes database connections. To prevent dozens of concurrent Serverless Functions from exhausting connection pools, I lowered the idle timeout so connections aren’t held open unnecessarily, while slightly increasing the connection pool size to prevent errors.

Cloud Functions and Cloud Scheduler

The RSS feed is generated on a recurring schedule using Cloud Functions and Cloud Scheduler. It took me a while to wrap my head around the difference between Cloud Functions and Cloud Run. Cloud Functions only support Google-supported runtimes, whereas Cloud Run runs on Docker containers, so any container image can be used. (Cloud Functions gen 2 actually runs on Cloud Run under the hood.)

For periodic, moderately time-consuming scripts like RSS generation, pairing Cloud Functions with Cloud Scheduler is ideal. Just be sure to adjust the Cloud Function memory allocation if your script consumes a lot of memory.

Functions can be triggered by different events, such as Pub/Sub or direct HTTP calls. I implemented it with Pub/Sub: the function executes whenever it receives a generate-rss event.

RSS feed generation diagram

Because Cloud Scheduler only runs a single job, it falls well within the free tier. Similarly, since the Cloud Function isn’t exposed to public web traffic, its execution volume stays well within free quotas—making this setup cost-effective for personal projects.

AWS S3 and CDN Setup

AWS S3 is a service I’ve used since the early days of this blog, paired with CloudFront. Initially, I used CloudFront’s default domain name, but upon further investigation, I realized custom domains are easy to attach. You can request a free SSL certificate directly through AWS Certificate Manager; once your CNAME records are verified, Amazon takes care of the rest automatically.

Additionally, when serving static assets to the public via a CDN, it’s best practice to disable direct public access on the S3 bucket to ensure all traffic goes through CloudFront. This improves security and provides DDoS protection via Web ACL rules to block malicious IPs.

The setup steps were as follows:

  • Go to the CloudFront console and create a distribution.

CloudFront console overview

  • If the origin is AWS S3, select “Origin access control settings (recommended),” which automatically generates the required bucket policy. Then, in the S3 console, enable “Block all public access” to invalidate bucket ACLs.

CloudFront origin access control settings

  • Request an SSL certificate from AWS Certificate Manager. When using DNS validation, ACM provides a CNAME name and value. Add these to your DNS records to prove domain ownership.

AWS Certificate Manager console

  • Add your custom domain in the CloudFront distribution settings, and you’re all set! Public certificates issued by AWS Certificate Manager are completely free. Now all static assets can be accessed via https://cdn.kalan.dev, which looks much cleaner.

Slate Editor

(Work in progress)

I wanted to build a Markdown editor tailored to my own workflow, which required a highly customizable framework, leading me to Slate. Slate’s architecture is extremely flexible, but almost all behaviors must be implemented yourself, and you have to deeply understand its underlying node data structure. Crafting it into what you want takes time.

So far, I’ve implemented a few features I’ve always wanted:

  1. Drag-and-drop or copy-paste uploads that automatically send images to the CDN and insert the returned markdown link.
  2. A shortcut key that triggers a Translation API call to translate selected text into other languages. This allows me to auto-translate straightforward paragraphs and manually refine more nuanced sections.

I’ll share more details once the editor is fully polished.

Future Improvements and Optimizations

At this point, a functional blog platform is complete. Here are a few things I’d like to improve and optimize moving forward:

  • Add unit tests and E2E tests: manually verifying over 140 posts has become too cumbersome.
  • Reduce database queries by caching post contents on the CDN.
  • Optimize JavaScript bundle size.
  • Implement full-text search.
  • Support uploading files directly into the database.
  • Article export and import capabilities.
  • To be continued…

Related Posts

Explore Other Topics