Migrating the Blog from Hexo to Astro

發表於
分類於 心得
This article is LLM-translated by GPT-4o, so the translation may be inaccurate or complete. If you find any mistake, please let me know.

This blog was originally built using the Hexo framework combined with the NexT theme. Recently, I rewrote it entirely using Astro, a modern SSG framework. This article will document my migration process and some insights.

Why Migrate?

Firstly, this blog was not entirely built with Hexo alone. I also used the gulp build system to help with some additional improvements, including but not limited to:

  • Compressing images
  • Compressing HTML/CSS/JS/SVG/XML/JSON…
  • Rewriting HTML tags like <a>, <h1>, <h2>, etc.

However, gulp itself is currently a framework that is not well-maintained. For example, at the time of writing this article, the last commit on the master branch was 11 months ago. I also encountered many bugs, some of which had PRs but no one to merge them, such as this one. This indicates that it essentially has no maintainers now and will only become harder to use in the future.

Additionally, I used Hexo’s plugin functionality to add some extra features, such as article encryption. Other more general features were directly contributed to the NexT theme (you can still find some features I added in NexT’s old repo). This architecture made it difficult to add or modify features because the changes were scattered and hard to test, resulting in poor maintainability of the entire blog.

Why Astro?

Hugo & Markdown

About two years ago, I also tried to move this blog to Hugo, but the biggest obstacle I encountered was its markdown parser’s limitations. Since a large portion of this blog consists of CTF writeups, many crypto problems require the use of mathematical formulas. Writing math in Markdown has a troublesome issue with backslashes \.

We know that in some special environments in LaTeX, \\ has many uses, such as in the following example:

$$
\begin{align*}
    a & = b + c \\
    & = d + e
\end{align*}
$$
a=b+c=d+e\begin{align*} a & = b + c \\ & = d + e \end{align*}

Here, \\ is used for line breaks, but \ is also an escape character in markdown. If the markdown parser does not handle mathematical formulas specially, it can easily cause problems.

The issue I encountered was that Hugo’s default markdown parser did not handle this well. The markdown parser I used in Hexo was pandoc, which handled this very well.

You might think, why not let Hugo use pandoc as the markdown parser?

Indeed, Hugo supports this feature, but it can only enable/disable pandoc through a simple option and cannot set additional parameters for pandoc. However, the PR to add this feature has existed since 2021 and has not been merged as of March 2025. This feature is a deal breaker for me, so I did not continue to try using Hugo.

Reasons for Choosing

As for other SSGs, I don’t have much to comment on because, besides Astro, I have only used Hexo and Hugo. I learned about Astro by casually checking what SSGs are currently available and found that Astro had good reviews. So, I spent some time understanding what Astro is all about.

I found that it is an SSG framework based on Vite that can generate MPA. It offers high flexibility, allowing customization from paths to various markdown parsers, as long as you are willing to write code. Most of the code can be completed using TypeScript, which has the advantage of static typing and is much more user-friendly than traditional string-based template SSGs.

Of course, because Astro offers high flexibility, it is not as specialized for blogs as Hexo and Hugo. Many features need to be implemented by yourself. Fortunately, the official documentation provides a tutorial on how to build a blog with Astro. Combining the tutorial with other people’s migration experiences, you can quickly learn how to use the Astro framework.

Migration Requirements

Migrating from Hexo to Astro is a significant task because it essentially means rewriting the entire website, including the CSS & JS provided by the NexT theme and many other features I added myself. Therefore, I wanted to clarify the requirements first to avoid major changes halfway through.

The specific requirements are as follows:

  1. Completely Static Site (can be hosted on Github Pages / Cloudflare Pages …)
  2. Retain the URLs of the old blog to avoid unnecessary 404 errors because Cool URIs don’t change
  3. The website’s Lighthouse Performance should not slow down

1. Completely Static Site

This can be easily achieved with Astro, so it’s not an issue.

2. Retain Old URLs

This is because someone might have referenced my blog articles elsewhere, and as a reader, clicking an external link that results in a 404 error is a terrible experience. Therefore, I hope to access articles using the same URLs.

3. Lighthouse Performance

The reason I used gulp for the old blog was that I had spent some time optimizing the website’s performance, achieving good results on Lighthouse. Therefore, I hope the new blog can maintain the same performance.

The old blog’s Lighthouse performance on desktop is as follows:

desktop performance on old blog

The old blog’s Lighthouse performance on mobile is as follows:

mobile performance on old blog

Others

Other unspecified aspects can be handled as needed. For example, I don’t have many thoughts on the theme, but I didn’t find any Astro themes I particularly liked. Therefore, I plan to complete it with the Pisces scheme used in the original NexT theme as a rough target.

Migration

The installation part is straightforward; just follow the official tutorial. At the time of writing this article, the Astro version is v5.

Collection

Astro has a collection mechanism that can be used to manage similar types of data, such as blog posts.

For example, I want to have a structure like this in src/content/article:

src/content/article
├── article-1
│   ├── index.md
│   └── image.jpg
├── article-2
│   ├── index.md
│   └── image.jpg
└── article-3
    ├── index.md
    └── image.jpg

Each markdown file can have a frontmatter to describe the article’s metadata, and the metadata schema can be defined as desired. Since I already have many written articles, I will continue using the original Hexo format and add some extra metadata that I implemented with Hexo plugins.

To achieve this, just add the following code in src/content.config.ts.

import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'

const article = defineCollection({
	loader: glob({
		pattern: '*/index.md',
		base: './src/content/article',
		generateId: x => {
			return x.entry.replace(/\/index.md$/, '')
		}
	}),
	schema: z.object({
		title: z.string(),
		date: z.coerce.date(),
		categories: z.string(),
		tags: z.array(z.string()).default(() => []),
		excerpt: z.string().optional(),
		math: z.boolean().default(() => false),
		draft: z.boolean().default(() => false),
		private: z
			.object({
				password: z.string(),
				password_message: z.string().default(() => '請輸入密碼'),
				wrong_password_message: z.string().default(() => '密碼錯誤'),
				fallback_content: z.string().default(() => '本篇文章已被加密')
			})
			.optional()
	})
})

export const collections = { article }

This specifies the target directory, the method for generating article IDs, and the metadata schema.

This design also solves a pain point I had with Hexo, where referencing images in markdown required directly specifying the file name in the Post Asset Folder, with a directory structure like:

article-1.md
article-1
└── image.jpg

This would cause the markdown editor (e.g., VSCode) preview to fail. (Related issue: hexojs/hexo#3245)

Article Model

Although reading collection data in Astro is simple with a single

const articles = await getCollection('article')

the data format design is not very user-friendly. For example, the date defined in the metadata above is a Date object, and for convenience, I want to pre-calculate the article’s year, month, date, etc. (needed for the URL), so a derived property mechanism is required.

Derived Properties

One way to achieve this is using zod’s transform + pipe, which I initially did. However, this method has a drawback: modifying content.config.ts requires re-syncing (re-reading and parsing all articles), resulting in a poor development experience.

Additionally, if you want to use Markdown Plugins (Remark & Rehype) to further process markdown, the zod method is not ideal. These plugins can only pass information to Astro through frontmatter, which is not exactly the same as the markdown frontmatter.

As I understand it, Astro’s content loader initially parses the frontmatter and saves the remaining markdown content. It only parses the markdown content when you call render, invoking your markdown plugins. Therefore, the frontmatter modified in the plugins will not reflect in the CollectionEntry<'article'> data.

The correct approach is to refer to the official Add reading time article, which explains that you need to retrieve the modified frontmatter from remarkPluginFrontmatter after calling render.

I discovered this because Astro does not natively support extracting excerpts from articles, while the original blog used <!--more--> HTML comments to separate excerpts from articles. To achieve this in Astro, you need to write a markdown plugin, as explained in the section below.

Defining a Model

So, what is the correct approach? I defined an additional model type to wrap the original CollectionEntry<'article'> provided by Astro. This avoids the repeated syncing issue and allows eager rendering when using values passed by markdown plugins.

Specifically, I did something like this:

import { type CollectionEntry, getCollection, render } from 'astro:content'
import { dateToUTCYMD } from './utils'

export async function getArticles() {
	const articles: CollectionEntry<'article'>[] = await getCollection('article', post => !post.data.draft)
	// sort by date, descending
	articles.sort((a, b) => {
		if (a.data.date.getTime() === b.data.date.getTime()) return 0
		return a.data.date < b.data.date ? 1 : -1
	})
	return await Promise.all(
		articles.map(async post => {
			let excerpt = post.data.excerpt
			if (!excerpt) {
				// we use a plugin the generate the excerpt
				const content = await render(post)
				excerpt = content.remarkPluginFrontmatter.excerpt as string
			}
			const excerptText = excerpt.replace(/<[^>]+>/g, '')
			const ymd = dateToUTCYMD(post.data.date)
			const permalink = `/${ymd.year}/${ymd.month}/${ymd.date}/${post.id}/`
			return {
				...post.data,
				id: post.id,
				permalink,
				ymd,
				excerpt,
				excerptText,
				post
			}
		})
	)
}
export type Article = Awaited<ReturnType<typeof getArticles>>[0]

This uses getCollection to retrieve article data and maps it to perform additional transformations, such as obtaining ymd, permalink, etc. When needed, it can call render to get values passed by markdown plugins.

Article Excerpt

I wrote a rehype (HTML processing) plugin to find <!--more--> comments and extract the content before it as the article excerpt.

Since rehype/remark are AST-based, achieving this is quite easy:

import type { RehypePlugin } from '@astrojs/markdown-remark'
import { toHtml } from 'hast-util-to-html'

export const rehypeExcerpt: RehypePlugin = () => {
	return (tree, file) => {
		if (!file.data.astro) return
		if (!file.data.astro.frontmatter) file.data.astro.frontmatter = {}
		const { frontmatter } = file.data.astro
		if (frontmatter.excerpt) return
		const moreIdx = tree.children.findIndex(node => node.type === 'raw' && node.value === '<!--more-->')
		const newTree = moreIdx !== -1 ? { ...tree, children: tree.children.slice(0, moreIdx) } : tree
		frontmatter.excerpt = toHtml(newTree)
	}
}

Then, use the plugin in astro.config.mjs:

import { rehypeExcerpt } from './plugins/post-excerpt'
export default {
    // ...
    markdown: {
        rehypePlugins: [rehypeExcerpt]
    }
}

This allows you to retrieve the excerpt using the remarkPluginFrontmatter in the getArticles function.

Routing

Articles

The old blog’s article URL format was /[year]/[month]/[date]/[slug]/, so to retain this, simply create src/pages/[year]/[month]/[date]/[slug].astro.

Then, define getStaticPaths to generate all paths based on the current collection of articles.

import { getArticles } from '@/utils/content'

export async function getStaticPaths() {
	const articles = await getArticles()
	return articles.map(article => {
		return {
			params: {
				year: article.ymd.year,
				month: article.ymd.month,
				date: article.ymd.date,
				slug: article.id
			},
			props: { article }
		}
	})
}

const { Content, headings } = await render(article.post)

Pagination

The old blog used /page/[page]/ for pagination, with 5 articles per page.

In Astro, achieving this is also simple. First, create src/pages/page/[page].astro, then use getStaticPaths to generate all paths and getStaticProps to retrieve articles for each page.

export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
	const articles = await getArticles()
	return paginate(articles, { pageSize: 5 })
}

const { page } = Astro.props

Then, render the entire page using the page object. For details, refer to the official tutorial.

Others

Once you can write articles and pagination, other features like tags and categories can be implemented similarly. Therefore, I won’t elaborate here as they are essentially the same.

Theme

There’s not much to say about the theme. This time, I used Tailwind CSS as the CSS framework and slowly built it based on the old blog as a template. Of course, I made many different changes, such as redesigning the behavior of the navbar & toc buttons on the mobile version.

However, this part took the most time and effort because writing CSS is really difficult QQ.

Most of the theme is written under src/layouts. For example, the common layout for the entire website is written in src/layouts/Layout.astro, which has about 300 lines of HTML/JS/CSS. Other pages directly import it as a component, similar to writing modern frontend frameworks like React/Vue.

Article Search

The old blog provided an article search feature through the NexT theme combined with hexo-generator-searchdb to provide.

It generated a search.xml containing all article content and used frontend JS for searching. However, the biggest problem with this approach is that the search.xml size becomes very large. For my pre-migration size, it was 1.3MB compressed, 7.4MB uncompressed.

This time, I wanted a better solution, so I found pagefind, an open-source library that can index a fully static website and dynamically search using frontend JS/wasm without any backend server support. This is perfect for my fully static website needs.

To implement this, simply use the astro-pagefind integration, and you can easily add a useful search feature to the blog.

Others

Other aspects, such as RSS, sitemap, robots.txt, etc., are covered in the Astro official tutorial, so I just implemented them accordingly.

Project Structure

After rewriting the entire blog, the project structure at the time of writing this article is as follows:

src
├── about.md
├── assets
│   └── maple3142.jpg
├── components
│   ├── CompactPostList.astro
│   ├── MarkdownContainer.astro
│   ├── Pagination.astro
│   ├── PostBlock.astro
│   ├── PostBody.astro
│   ├── PostFooter.astro
│   ├── PostHeader.astro
│   └── TOC.astro
├── content  (去除掉了 article 資料夾)
├── content.config.ts
├── info.ts
├── layouts
│   └── Layout.astro
├── pages
│   ├── 404.astro
│   ├── about.astro
│   ├── archives.astro
│   ├── categories
│   │   ├── [category].astro
│   │   └── index.astro
│   ├── index.astro
│   ├── page
│   │   └── [page].astro
│   ├── robots.txt.ts
│   ├── rss.xml.ts
│   ├── search.astro
│   ├── tags
│   │   ├── index.astro
│   │   └── [tag].astro
│   └── [year]
│       └── [month]
│           └── [date]
│               └── [slug].astro
├── styles
│   └── global.css
└── utils
    ├── content.ts
    ├── crypto.ts
    └── utils.ts

You can see that the entire structure is logical, similar to writing with frameworks like React/Vue. It’s just about splitting reusable parts into components and importing them as needed.

Performance Improvements

Lighthouse performance on the homepage is essentially unchanged from the old version because the old blog already had a high score, leaving little room for improvement. However, there are improvements on the article pages.

For example, the TSCCTF 2025 Writeups scored 85 on mobile in the old version:

old blog, tscctf 2025 writeup lighthouse result on mobile

The new version scored 90 on mobile:

new blog, tscctf 2025 writeup lighthouse result on mobile

Insights

After rewriting, I am quite satisfied with the results. The initial goals were achieved, and the entire blog is much easier to modify. For example, if I am dissatisfied with the current theme in the future and want to adjust the layout, it will be very easy.

Astro Advantages

Astro has many advantages, such as the following, which I found particularly noticeable while writing this article:

  1. (Almost) Full TypeScript Coverage

This makes it easy to refactor code at any time without any obstacles. The template language is also JSX, which has the advantages of autocomplete and type checking compared to traditional string-based template engines.

  1. High Flexibility

At least for someone who has only used Hexo & Hugo, this is very noticeable. Astro’s collection design is particularly elegant. For example, if I want to integrate my My CTF Challenges into my blog, I just need to define a collection & custom loader to read the markdown from that repo.

With Hexo, I can’t think of how to achieve this because its collection is essentially predefined as posts, making it difficult to extend.

  1. Remark/Rehype AST

Writing AST-based plugins is very convenient. Unlike using gulp, where I had to find an HTML parser to modify the desired content in a bunch of HTML, and the performance was poor, AST-based tools are much better.

Astro Disadvantages

The biggest pitfall, in my opinion, is the issue mentioned in the Derived Properties section, where you can’t directly get the plugin-modified frontmatter from the collection data. However, I believe this is an unavoidable issue due to Astro’s design of separate parse and render stages.

Another potential disadvantage is that Astro is quite unfriendly to non-frontend developers compared to blog-oriented frameworks like Hexo & Hugo. Many features need to be implemented by yourself (e.g., article excerpts). Therefore, I wouldn’t recommend Astro to non-frontend developers. However, for frontend developers, Astro is a very comfortable framework to use.