返回文章列表
程式2026年4月19日 17:46

使用 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 需要設定快取時間來更新頁面,改成是有變動才進行更新。 實作的流程:

  1. 建立一支 API,透過 revalidatePath 觸發更新頁面
  2. 在 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"
      />
    );
  }

next-image-example

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 的處理)。

最後我認為,如果目標只是寫文章為出發點的話,直接選用自己喜歡的部落格平台就好了,專注於寫文章這件事還是更重要的。畢竟很有可能是花了時間架設完網站之後,就沒有然後了 (感覺我也會這樣)。

所以重點是看你想要開車,還是想要造車甚至造輪子,選擇適合自己的方式來完成就好了。