使用 Next.js 建立部落格
前言
這篇文章會簡單介紹架設這個部落格所使用的技術,以及選擇的原因。
我的需求很單純:想要架設自己的網站,有個可以輸出文字的地方。技術上我選擇使用 Next.js (v16) 與 Prisma 做全端開發,原因是在職場中沒什麼機會可以用上較新的技術、框架,因此自己的專案就是使用新技術的最佳時機了。
以及最初的目標是養成穩定的寫作習慣,因此不希望一開始就把整體網站架構設計得非常大,花費太多時間在建構網站上反而就有點本末倒置了,所以現階段會優先完成必要功能,後續再逐步更新。
技術選擇
前後端開發:Next.js (v16)
部落格有 SEO 的需求,需要由伺服器渲染網頁 (SSR),與靜態生成 (SSG) 的能力,可以在建置時期直接生成靜態網頁以提升讀取速度,而且在 RSC 中直接使用 Prisma 讀取資料庫,開發體驗非常流暢。
部署:Vercel
使用 Next.js 官方部署平台,部署 Vercel 非常簡單快速,不用自行架設機器、處理 CI/CD,同時還有圖片優化、CDN 等功能,對個人專案來說相當方便。
資料庫:Supabase (PostgreSQL、Storage)
常見的 Next.js 部落格做法是將文章以 .md 或 .mdx 檔案存放在專案中,在建置時一起把所有文章轉為靜態頁面,這樣就不需要去維護後端與資料庫。
但我選擇了使用資料庫來存放文章內容,主要原因如下:
- 文章更新:不需要每次新增/編輯文章後重新 Build 整個網站,只需要重新生成頁面
- 資料管理性:可以透過資料庫設計文章的分類與標籤等,就不需要依賴文章檔的 metadata 管理
- 網站擴充性:未來可以擴充更多的功能,例如留言、按讚、觀看次數等功能,不用依賴第三方服務
當然,缺點就是需要額外維護資料庫與後端邏輯。因此選擇使用 Supabase 這類 BaaS 的服務,可以降低資料庫的維護成本,並且搭配 Storage 存放文章圖片。
ORM:Prisma
方便的 ORM 工具。可以直接用 TypeScript 寫資料庫的 Query,並且有型別推斷,有效提升開發體驗。
樣式:Shadcn UI
建立在 TailwindCSS 上的 UI 元件集合,能快速處理網站整體樣式。有別於一般的 UI library,是直接導入專案程式碼,可以完全掌握所有的元件樣式並做客製化處理。
文章格式
文章仍然使用 Markdown 格式儲存在資料庫,原因除了撰寫方便外,如果未來需要搬移資料,也相對好處理。取得 Markdown 內容在前端顯示時,需要解析並轉為 React 元件,會使用到以下套件:
- next-mdx-remote:解析 Markdown 轉為 React 元件
- tailwindcss/typography:文章排版樣式
- rehype-pretty-code:程式碼語法的樣式
- remark-gfm:更多 Markdown 語法樣式
實作細節
文章處理流程
一開始還不想要建立一個撰寫文章的後台,如同開頭所說,希望先完成基本的需求。目前做法是,寫一支腳本解析 .md 檔案的 metadata 與文章內容,並寫入資料庫。如果之後覺得不太方便,才會優化此流程。
網頁渲染方式
由於使用資料庫,如果每次都是透過 SSR 即時取得資料,這樣除了增加資料庫的負擔,進入網站的速度也會變得非常的慢,且部落格網站內容更新頻率很低,因此適合使用靜態頁面處理。 Next.js 提供幾種渲染方式:
SSG (Static Site Generation)
網站建置時就把所有頁面產生好,這樣每個使用者進入網站都會看到預先準備好的文章內容,但如果使用純 SSG 的話,每次更新文章都需要觸發 Vercel 重新 Build 整個網站,流程上稍微麻煩了點,且文章開始變多時,也會增加建置時間及運算成本。
ISR (Incremental Static Regeneration)
透過設定快取時間來更新頁面。當使用者進入了一個過期頁面,會觸發去資料庫拿資料來更新頁面,但這種方式會有兩個問題:一是如果文章沒有更新但快取時間已過期,系統仍然會浪費效能去拿同一份資料來更新頁面,二是文章有更新但快取時間還沒過期,就會導致使用者無法即時看到最新的內容。
On-demand Revalidation
最終選擇的做法,不同於一般 ISR 需要設定快取時間來更新頁面,改成是有變動才進行更新。 實作的流程:
- 建立一支 API,透過 revalidatePath 觸發更新頁面
- 在 Supabase 設定 Webhook,只要資料庫的文章相關欄位有更新時,就呼叫這支 API
這樣只要當我新增或編輯了一篇文章,這篇文章就會自動生成新的靜態頁面,達到保持網站的即時更新、讀取速度快、同時避免浪費不必要資料庫的查詢。
Markdown 圖片處理
透過 next-mdx-remote 來攔截 Markdown 的 img 格式,並轉換成 next/image,以利用 Next.js 的圖片優化能力。
// src/app/posts/[slug]/page.tsx 文章頁
<div className="prose sm:prose-lg dark:prose-invert max-w-none">
<MDXRemote
source={post.content}
components={{ img: MdxImage, a: MdxUrl }}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypePrettyCode]],
},
}}
/>
</div>
// MdxImage component
import Image from "next/image";
import type { ImgHTMLAttributes } from "react";
export function MdxImage({ src, alt }: ImgHTMLAttributes<HTMLImageElement>) {
if (!src || typeof src !== "string") {
return null;
}
return (
<Image
src={src}
alt={alt || "blog-post-image"}
width={0}
height={0}
sizes="100vw"
className="w-full h-auto object-cover"
/>
);
}![]()
Markdown 影片處理
影片我選擇將影片上傳到 YouTube,一樣透過 next-mdx-remote 攔截 YouTube 網址,並轉換成 embed 網址為 iframe 顯示,省下儲存影片空間、頻寬成本。
// MdxUrl component
import Link from "next/link";
const getYoutubeUrl = (href: string): string | null => {
// 可以貼上一般的 YouTube 連結,讓程式轉換成 iframe 用的 embed 連結
const youtubeRegex =
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/i;
const match = href.match(youtubeRegex);
if (!match || !match[1]) {
return null;
}
return `https://www.youtube.com/embed/${match[1]}`;
};
export function MdxUrl({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const youtubeUrl = getYoutubeUrl(href);
if (youtubeUrl) {
return (
<iframe
src={youtubeUrl}
title="YouTube video player"
className="my-6 block aspect-video w-full overflow-hidden rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
);
}
// 同時處理如果是內站的連結就改成用 CSR 的方式跳轉
const isInternalUrl =
href.startsWith("/") ||
href.startsWith("#") ||
href.includes(process.env.NEXT_PUBLIC_BASE_URL || "");
if (isInternalUrl) {
return <Link href={href}>{children}</Link>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}其他
其他還有一些實作優化這篇文章沒有提到的,例如 og-image 動態生成、Prisma 實作方式、其他 Markdown 格式處理,因為篇幅關係,有機會未來會再做介紹。
結尾
建置這個網站目前的金錢成本為 0 元 (不包括自訂網域費用,網域費用一年約 10 ~ 20 鎂)。
因為 Vercel 與 Supabase 的免費方案已經蠻足夠支撐個人專案使用了,但需要注意額度限制,避免不必要的消耗 (例如 SSR 的處理)。
最後我認為,如果目標只是寫文章為出發點的話,直接選用自己喜歡的部落格平台就好了,專注於寫文章這件事還是更重要的。畢竟很有可能是花了時間架設完網站之後,就沒有然後了 (感覺我也會這樣)。
所以重點是看你想要開車,還是想要造車甚至造輪子,選擇適合自己的方式來完成就好了。