How I Built My Personal Website
Filed under Web Development on November 11, 2024. Last updated on November 18, 2024.
Table of Contents
Why?
When I set aside all superficial reasons, I realized that I simply wanted to create a personal website. Whether it’s technology, design, or writing, the process itself is beautiful. If you don’t enjoy the process, social media might be a better choice.
Back in my university days, I wanted to become an architectural designer. However, by a twist of fate, I became a software engineer and found it equally enjoyable. I loved spending time in the library, and one day I stumbled upon CSS Zen Garden. That book opened my eyes. Perhaps that’s when the seed of designing and implementing my own website was planted.
Overview
In this article, I will introduce two solutions. I’ve tried both, and each has its pros and cons, which I will elaborate on in the following sections.
Solution | Pros | Cons |
---|---|---|
GitHub Pages + Jekyll | 1. Free 2. Easy setup 3. Recognizable domain | 1. Limited customization 2. Poor scalability |
AWS Amplify + Next.js | 1. Flexible 2. Highly scalable | 1. Higher development cost with Next.js 2. Maintenance cost with AWS Amplify |
In summary, if you want to quickly publish static content and don’t have high demands for customization, GitHub Pages + Jekyll is a great choice. However, if you want to fine-tune the details of your website, GitHub Pages + Jekyll falls short, while AWS Amplify + Next.js offers much more flexibility.
GitHub Pages + Jekyll
Using GitHub Pages, you can set up a static website in minutes by following the official documentation and using Jekyll. GitHub provides a free subdomain (username.github.io
, where username
is your GitHub username or organization name), and Jekyll offers a range of ready-to-use themes.
If your primary need is to quickly publish content, GitHub Pages + Jekyll is an excellent choice. However, I encountered some issues during my usage:
- Jekyll is implemented in Ruby. I lack sufficient Ruby experience, and deep customization would require me to read Jekyll’s source code, which ultimately led me to abandon it.
- Jekyll generates static pages using templates. While templates simplify creating static files, they become limiting when your pages require complex styles and interactions.
- Themes have limited customizability, and compatibility between different themes can be problematic.
Due to these reasons, I decided to move on from GitHub Pages + Jekyll after maintaining it for a while.
AWS Amplify + Next.js
When I gave up on Jekyll, I didn’t consider hosting static websites built with other frameworks on GitHub Pages. Instead, I switched to AWS Amplify.
Compared to GitHub Pages, AWS Amplify is not only professional and comprehensive but also very easy to get started with, at a negligible cost.
During this process, I discovered Next.js. After some research, I decided to use it.
- The React Framework for the Web: I had used React around 2017 to develop an internal single-page application. Its powerful features, modularity, and high development efficiency left a deep impression on me. When I read about Next.js, I immediately decided to use it after a brief investigation.
- React Client Components vs. React Server Components: In 2020, the React team introduced the React Server Components RFC, a major update that significantly improved React’s performance and applicability.
- CSR (Client-Side Rendering) vs. SSR (Server-Side Rendering): As a personal website, SEO is essential, making SSR indispensable. Considering performance, SSR and SSG (Static Site Generation) are better choices.
Overall, using Next.js was a smooth experience.
Hosting Next.js on AWS Amplify
Hosting Next.js on AWS Amplify is straightforward. There’s an official guide available for reference.
Migrating Jekyll Blog to Next.js
Next.js offers two router modes: App Router and Pages Router. App Router supports new features, so it’s better to use it directly.
Based on this, you can migrate your blog pages. Refer to the Next.js Routing documentation for details.
Two important features are Route Groups and Dynamic Routes. Route Groups help manage the mapping between code structure and URL paths, while Dynamic Routes are essential for dynamically generating blog post URLs.
Dynamic Routes and generateStaticParams
// file app/blog/[slug]/page.tsx
export default async function BlogPage({ params }: { params: Promise<{ slug: string }>}) {
const slug = (await params).slug
return (
<main>My Post: {slug}</main>
)
}
If you use Dynamic Routes this way, all requests are processed on demand.
Suppose we deploy the following code to AWS Amplify; it will cause the relevant dynamic routes to be processed dynamically on the server side. If we retrieve articles based on the slug parameter, the article will be dynamically loaded on the server.
# Pay attention to 'ƒ (Dynamic) server-rendered on demand'
npm run build
> example-app@0.1.0 build
> next build
▲ Next.js 15.0.3
Creating an optimized production build ...
✓ Compiled successfully
✓ Linting and checking validity of types
✓ Collecting page data
✓ Generating static pages (6/6)
✓ Collecting build traces
✓ Finalizing page optimization
Route (app) Size First Load JS
┌ ○ / 5.57 kB 105 kB
├ ○ /_not-found 896 B 101 kB
├ ○ /blog 139 B 100 kB
└ ƒ /blog/[slug] 139 B 100 kB
+ First Load JS shared by all 99.9 kB
├ chunks/4bd1b696-80bcaf75e1b4285e.js 52.5 kB
├ chunks/517-d083b552e04dead1.js 45.5 kB
└ other shared chunks (total) 1.88 kB
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
You can see from the build output, The path /blog/[slug]
is dynamic(server-rendered on demand).
To optimize, use the generateStaticParams function:
// file app/blog/[slug]/page.tsx
export async function generateStaticParams() {
return [{slug: 'hello'}]
}
export default async function BlogPage({ params }: { params: Promise<{ slug: string }>}) {
const slug = (await params).slug
return (
<main>My Post: {slug}</main>
)
}
This function essentially provides all the values for the dynamic routes during the build phase and generates static pages based on these values.
# Pay attention to '● (SSG) prerendered as static HTML (uses generateStaticParams)' and line '/blog/hello'.
npm run build
> example-app@0.1.0 build
> next build
▲ Next.js 15.0.3
Creating an optimized production build ...
✓ Compiled successfully
✓ Linting and checking validity of types
✓ Collecting page data
✓ Generating static pages (7/7)
✓ Collecting build traces
✓ Finalizing page optimization
Route (app) Size First Load JS
┌ ○ / 5.57 kB 105 kB
├ ○ /_not-found 896 B 101 kB
├ ○ /blog 139 B 100 kB
└ ● /blog/[slug] 139 B 100 kB
└ /blog/hello
+ First Load JS shared by all 99.9 kB
├ chunks/4bd1b696-80bcaf75e1b4285e.js 52.5 kB
├ chunks/517-d083b552e04dead1.js 45.5 kB
└ other shared chunks (total) 1.88 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
This way, we can see that the /blog/[slug]
path is now pre-rendered as static HTML, and based on the parameters returned by the generateStaticParams
function, the content corresponding to the /blog/hello
path is also generated.
Of course, in actual implementation, we can use the slug
parameter to fetch markdown files, compile them, and render the content onto the page.
Using remark
to Process Markdown
To render Markdown files into HTML pages, I tried two approaches.
Loading Markdown Files as HTML
This method is straightforward and easy to understand.
- Read the Markdown file based on the
slug
parameter. - Compile the file's content into HTML.
- Set the HTML to a JSX element using the
dangerouslySetInnerHTML
property.
export async function markdownToHtml(content: string) {
const processedContent = await remark()
.use(remarkMath)
.use(html, { sanitize: false })
.use(remarkGfm)
.use(remarkGemoji)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeKatex)
.use(rehypeStringify)
.process(content)
return processedContent.value.toString()
}
You can choose plugins based on your specific needs, but pay attention to the order of the plugins. Improper ordering can lead to errors.
Importing Markdown as MDX
Importing Markdown as MDX should theoretically be simpler than compiling it into HTML. However, since I used frontmatter
, additional plugin configuration was required.
import createMDX from '@next/mdx'
import remarkFrontmatter from 'remark-frontmatter'
import remarkMdxFrontmatter from 'remark-mdx-frontmatter'
import remarkMath from 'remark-math'
import remarkGfm from 'remark-gfm'
import remarkGemoji from 'remark-gemoji'
import remarkToc from 'remark-toc'
import rehypeKatex from 'rehype-katex'
const withMDX = createMDX({
options: {
remarkPlugins: [
remarkFrontmatter,
remarkMdxFrontmatter,
remarkMath,
remarkGfm,
remarkGemoji,
remarkToc,
],
rehypePlugins: [
rehypeKatex,
],
},
})
Next.js's official documentation isn't particularly helpful in this case. To understand what these plugins do, we need to explain a bit about the underlying principles.
MDX is a JSX-based extension of Markdown. Essentially, MDX is compiled into JSX components. Therefore, when we use import
to load an MDX file, the variables that can be exported depend on the MDX transpilation process.
By default, Next.js only transpiles standard MDX into JSX, so importing an MDX file typically provides only a default export. If we want to access frontmatter metadata, we need to configure plugins, which is why remarkFrontmatter
and remarkMdxFrontmatter
are necessary.
Addressing Errors with Metadata in VS Code
If you’re using VS Code like I am, you might encounter an error such as this:
This happens because the default MDX module doesn’t export a metadata variable. Even if the variable exists in the transpiled module, it’s not included in the module definition.
To fix this, you need to customize the MDX module definition.
// global.d.ts
export interface Frontmatter {
title: string;
createdAt: string;
updatedAt: string;
author: string;
language: string;
tags: string[];
summary: string;
}
declare module '*.mdx' {
import { MDXProps } from 'mdx/types';
const MDXComponent: (props: MDXProps) => JSX.Element;
export default MDXComponent;
// Add named exports based on your MDX configuration
export const frontmatter: Frontmatter;
}
To avoid conflicts with Next.js's metadata
, I named the exported frontmatter explicitly as frontmatter
. You can use any name you prefer in the plugin configuration.
const withMDX = createMDX({
options: {
remarkPlugins: [
[remarkMdxFrontmatter, "myFrontmatter"],
],
},
})
Now you can import MDX files along with their associated frontmatter metadata directly into your code. At this point, we have essentially completed the migration from Jekyll to Next.js.
import { notFound } from "next/navigation";
export async function MDXPage({ params }: { params: { slug: string } }) {
try {
const { default: Content, frontmatter } = await import(`@/posts/${params.slug}.mdx`)
return (
<main>
<h1>{frontmatter.title}</h1>
<p><small>{`Filed under ${frontmatter.tags}`}</small></p>
<Content />
</main>
)
} catch {
notFound()
}
}
Summary
Since starting with AWS Amplify + Next.js to build my personal website, I’ve spent most of my leisure time on its architecture and styling, rather than updating content. However, I’ve gained plenty of insights during this process, which I’ll share in future articles.