2024 年我用哪些 Tech Stack 來重構自己的網頁

2024 年,我花了將近一年的時間重構個人網站。從 2021 年初次用 Next.js 和 TypeScript 練手開始,這個專案已經陪伴我走過了三年。當時的我對 React 還不熟悉,TypeScript 也是第一次在專案中使用,於是決定用這個專案來練習框架、語言,並培養寫作習慣。隨著技術能力的成長,我開始對最初的 code base 進行重構與轉換,才有了現在的網站樣貌。

CChia1104
文章24 分鐘閱讀

2024 年,我花了將近一年的時間重構個人網站。從 2021 年初次用 Next.js 和 TypeScript 練手開始,這個專案已經陪伴我走過了三年。當時的我對 React 還不熟悉,TypeScript 也是第一次在專案中使用,於是決定用這個專案來練習框架、語言,並培養寫作習慣。隨著技術能力的成長,我開始對最初的 code base 進行重構與轉換,才有了現在的網站樣貌。

chia1104.dev

網站開發目標

在這次重構中,我設定了幾個明確的目標:

  • 可以在同一個 code base 管理多個專案(轉向 monorepo)
  • 把可共用的方法拆出來,以利後續開發其他專案時好一起共用
  • 透過主流工具開發,減少自行維護時間但又要能方便客製化

2024-tech-stack

Monorepo (Turborepo)

為什麼選擇 Monorepo?

最早這個網站是用靜態的 mdx 來生成文章(next-mdx-remote),後來我想嘗試透過 Next.js 的 ISR 來更新文章。那時候剛好 Next.js 12 的功能穩定了,於是就萌生了做後台來管理文章的想法。同時,Turborepo 也剛被 Vercel 收購,我也在那時候認識了 monorepo 的架構。

Turborepo

Turborepo 是專為 JavaScript 和 TypeScript 程式碼打造的高效建置系統。它專為擴展 monorepos 而設計,同時也使多個工作區中對應的工作流程更快速。

turborepo

實際應用經驗

往 monorepo 架構開發至今已經兩年多的時間,從自己摸索到逐步掌握較複雜專案的開發,這是一個持續學習的過程。

過去與其他同事開發 side project 時,每當新開一個專案都需要把先前在其他專案用過的方法或元件複製到現有專案裡,這讓開案初期花費不必要的時間。這也是我更想嘗試 monorepo 的原因。

package.json

當然過程中也遇上不少問題,包含部署流程的調整及本地開發優化。例如透過 cache 的方式加快 linttype check 的速度,這些都是實作後發現可以改進的地方。

tRPC

最早這個專案打算以 T3 的架構去開發,而 tRPC 是其中一個核心工具,有助於前後端的 API 串接,並共享輸入和輸出的型別定義。

T3 Stack

T3 StackTheo 所創建的,基於 Next.js 和 TypeScript 的 typesafety 特性做延伸,而 tRPC 正好是其中專門實作前後端 API 串接的形式。

傳統 API routes(Next.js)的問題

過去若是用 Next.js 的 API routes 開發 API,需要分別針對前後端去定義 API 的 input 跟 output 的型別和驗證。而且若後端的商業邏輯做了修改,前端也要確認是否需要修改並對應型別,相對非常耗時。

pages/api/feed/[feedId]/index.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
import { deleteFeed, getFeed } from "@/server/feed/services";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const session = await getSession({ req });
  if (!session) {
    return res.status(401).json({ message: "Unauthorized" });
  }
  switch (req.method) {
    case "delete":
      try {
        const result = await deleteFeed(req.query.feedId as string);
        return res.status(204).json(null);
      } catch (error) {
        console.log(error);
        return res.status(400).json({ message: "Bad Request" });
      }
    case "get":
      try {
        const result = await getFeed(req.query.feedId as string);
        return res.status(200).json(result);
      } catch (error) {
        console.log(error);
        return res.status(400).json({ message: "Bad Request" });
      }
    default:
        return res.status(405).json({ message: "Method Not Allowed" });
  }
}
helpers/feed/index.ts
const getFeedById = async (feedId: string): Promise<
  ApiResponse<
    {
      id: string;
      name: string;
      // ...
    }[]
  >
> => {
  try {
    const res = await fetch(`/api/sign/${feedId}`, {
        method: "GET",
        credentials: "include",
    });
    if(!res.ok) {
        // handle error
    }
    return await res.json();
  } catch (error) {
    // handle 500 error
  }
};

tRPC 的解決方案

tRPC 的導入正好解決了這個問題。我只需要花時間維護後端的 service,前端直接 infer 到 service 的型別,剩下的錯誤處理 tRPC 也幫忙處理了。

  • Step 1:在 server side 的 routes 定義方法
server/trpc.ts
const feedsRouter = createTRPCRouter({
  getFeedsWithMeta: protectedProcedure
    .input(z.object({ /** DTO Schema **/ }))
    .query((opts) => {
      return getFeedsWithMeta(opts.ctx.db, opts.input);
    }),
});

export const appRouter = createTRPCRouter({
  feeds: feedsRouter,
});

// export type definition of API
export type AppRouter = typeof appRouter; 
  • Step 2:在 client side 整合 React Query
client/trpc.ts
"use client";

import { createTRPCReact } from "@trpc/react-query";

import type { AppRouter } from "@/server/trpc"; 

export const api = createTRPCReact<AppRouter>({ 
  abortOnUnmount: true, 
}); 
  • Step 3:在對應的 component 中引用
component/feed-list.tsx
"use client";

const FeedList: FC<Props> = (props) => {
  const { initFeed, nextCursor, query = {} } = props;
  const {
    data,
    isSuccess,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = api.feeds.getFeedsWithMeta.useInfiniteQuery(query, { 
    getNextPageParam: (lastPage) => lastPage?.nextCursor, 
  }); 

  // ...
};

使用建議

tRPC 算是相對新的全端框架,在前期的 setup 需要花一些時間熟悉。但設定完後,後續的開發大幅減少了 API 串接的時間。tRPC 特別適合:

  • 需要在多個前端專案間共享 API 的場景
  • 重視型別安全和開發體驗的全端專案
  • 使用 TypeScript 生態系的團隊

Hono

Hono 是一個輕量且可以同時在多個 runtime(Node、Bun、Deno)運行的 JavaScript 框架,寫法與 Express 類似。

server.ts
import { Hono } from 'hono'
const app = new Hono()

app.get('/', (c) => c.text('Hono!'))

export default app

它還支援 tRPC 和 Auth.js 等第三方 middleware,這幾個都是我當時正在使用的工具,正好方便做整合,於是後來就選擇用 Hono 來開發部分 API。

從 Nest.js 轉換的原因

最早我開發的 API 是用 Nest.js。一開始選擇 Nest.js 的原因是這是我最早學習的 Node.js server 框架。Nest.js 的優勢在於專案的結構性和管理,並且多半用 TypeScript 開發。一開始 Nest.js 的結構也幫助我快速理解了後端應有的架構模式。個人認為若你是後端新手,Nest.js 是不錯的選擇。

但是 Nest.js 在當時的版本有一個很大的問題,它主要支援 CommonJS 的套件格式。然而到了 2024 年,已經有不少主流套件只支援 ES Module 格式,這大大影響了開發體驗(DX)。若要在 CommonJS 裡使用 ES Module 的套件,需要非同步地載入進來,而且該方法一定要是非同步的

guards/auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(@Inject(DRIZZLE_PROVIDER) private readonly db: DB) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const createActionURL = ( 
      await import("@auth/core") 
    ).createActionURL; 

    // ...
  }
}

而且當時 Nest.js 只支援 Node runtime,若想用 Bun 來解決 CommonJS 和 ES Module 的問題也沒辦法。所以後來就果斷換成 Hono 並用 Bun 運行,避免 CommonJS 和 ES Module 的相容問題,同時確保開發體驗。

Better auth

Better auth 是一個專門應用在 TypeScript 專案的驗證工具,具備以下主要功能:

  • 支援主流框架,包括 React、Vue、Svelte、Astro、Solid、Next.js、Nuxt、Tanstack Start、Hono 等。
  • 內建對電子郵件和密碼驗證的支持,具有 session 和帳戶管理功能。
  • 支援多個 OAuth,包括 GitHub、Google、Discord、Twitter 等。
  • 支援多種登入模式,包含多重要素驗證、Passkey等。
  • 透過成員、組織、團隊和邀請以及存取控制來支援多租戶。
  • 客製化插件,更方便的條整登入模式。
  • 提供 CLI 來做 DB migration
libs/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db"; // your drizzle instance
 
export const auth = betterAuth({
    database: drizzleAdapter(db, {
        provider: "pg", // or "mysql", "sqlite"
    }),
    socialProviders: { 
        github: { 
            clientId: process.env.GITHUB_CLIENT_ID, 
            clientSecret: process.env.GITHUB_CLIENT_SECRET, 
        } 
    }, 
});

為什麼選擇 Better Auth?

Better Auth 是我最後選定的登入驗證工具。選擇它的主要原因包括:

  1. 更好的客製化能力:相比其他方案,Better Auth 更容易做深度客製化
  2. 靈活的儲存方案:它可以透過第二個儲存方案來存 session,可以把 Redis 和 PostgreSQL 做分開管理
  3. 內建豐富功能:預設提供的 plugin 正好都符合我的使用需求(Passkey、OIDC、MFA 等)

透過第二種儲存方式來存取 session,secondary storage

betterAuth({
	// ... other options
	secondaryStorage: {
		// Your implementation here
	}
})

Auth.js(Next Auth)的比較

一開始自己是用 Auth.js。這個工具在 Next.js 專案中其實非常方便,只需要調整 config 的設定,前後端就有現成 API 可供使用。

libs/auth.ts
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
export const { auth, handlers } = NextAuth({ providers: [GitHub] })
middleware.ts
export { auth as middleware } from "@/libs/auth"
app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/libs/auth"
export const { GET, POST } = handlers

然而 Auth.js 本身實作了很多方法,若要做客製化功能會相對花時間。它預設的儲存模式只能選一種 adapter,若要把 Redis 和 PostgreSQL 共用會需要再寫一份 adapter。而且它沒有提供較為 headless 的方法,若實作在自己的後端,對應的 action 都只會以 Response 做回應,很難以 JSON 或物件去做其他 service 的整合。

  • Better Auth 取得 session 的方式(回傳一般物件):
const session = await auth.api.getSession({ headers: c.req.raw.headers });
//    ^^^ 處理驗證
  • Auth.js 取得 session 的方式(回傳 Response):

取得 session 可以用 getSession 來做取得,不過他們內部的實作方法其實是一樣的 Auth.js - Express

import { createActionURL } from "@auth/core";

const url = createActionURL(
  "session", 
// ^^^ action name (getSession)
  request.protocol,
  new Headers(request.headers as HeadersInit),
  process.env,
  "/auth"
);

const response = await Auth(
  new Request(url, { headers: { cookie: request.headers.cookie ?? "" } }),
  {
    secret: env.AUTH_SECRET,
  }
);

const session = (await response.json()) as Session | null;
//    ^^^ 處理驗證
功能特性Auth.jsBetter Auth
框架支援主要針對 Next.js 優化支援多種框架(React、Vue、Svelte 等)
客製化難度較高(需要了解內部實作)較低(提供 headless API)
儲存方案單一 adapter支援雙儲存方案(如 Redis + PostgreSQL)
API 回應格式Response 物件JSON 物件
內建功能OAuth、Email 驗證OAuth、Email、MFA、Passkey、OIDC
生態系成熟度成熟且穩定較新但功能完整
適用場景Next.js 專案,需要快速整合需要高度客製化的全端專案

SaaS 供應商

過去我也嘗試了幾個 SaaS 方案,不過後來想說既然都有自己的資料庫在儲存其他資料,那就自己簡單地實作登入功能。但這些方案我認為若要快速搭建 MVP 應用的話是很好的選擇。

Clerk

clerk

Clerk本身提供了很多登入方式,並且在後台可以做管理跟設定,專案裡不需做額外的方法實作,可以直接用他們的 API 來完成專案其他功能。

除此之外,它還有現成的 UI 介面可以放到專案裡使用,並且支援很多語言。像是原生的手機應用(Kotlin 和 Swift)甚至 Expo 都有支援。若真的在做產品開發,Clerk 會是很好的選擇。

Logto

logto

Logto 與 Clerk 類似,但值得一提的是 Logto 可以 self-host。若不想用這些 cloud 版本的可以嘗試自己部署,對於一些應用的專案整合上會比較好管理。

它同樣也支援 UI 介面,不過是導向 Logto 本身 host 的頁面。

Fumadocs

從 next-mdx-remote 到 Fumadocs

過去就嘗試以 mdx 在寫文章,並搭配 next-mdx-remote。若文章是存在專案的資料夾裡,可以直接讀取出來再透過 Next.js 去 pre-build 這些頁面。

  • Page 架構
pages/feed/index.tsx
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'

const components = { Test }

export default function Page({ source }) {
  return (
    <div className="wrapper">
      <MDXRemote {...source} components={components} />
    </div>
  )
}

export async function getStaticProps() {
  // MDX text - can be from a local file, database, anywhere
  const source = 'Some **mdx** text'
  const mdxSource = await serialize(source, {
    parseFrontmatter: false,
    mdxOptions: {
      remarkPlugins: [],
      rehypePlugins: [],
    },
  })
  return { props: { source: mdxSource } }
}
  • App 架構
app/feed/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import { compileMDX } from "next-mdx-remote/rsc";

const components = {
  h1: (props) => (
    <h1 {...props} className="large-text">
      {props.children}
    </h1>
  ),
}

function MDXContent(props) {
  return (
    <MDXRemote
      {...props}
      components={{ ...components, ...(props.components || {}) }}
      options={{
        parseFrontmatter: false,
        mdxOptions: {
          remarkPlugins: [],
          rehypePlugins: [],
        },
      }}
    />
  )
}

const Page = async ({
  params,
}: {
  params: Promise<{
    slug: string;
  }>;
}) => {
  const { slug } = await params;
  const content = await getContent(slug)
  //                    ^^^ any fs function

  return <MDXContent source={content}>
}

export default Page;

next-mdx-remote 簡化了 MDX 的編譯方法,但剩下的 plugin 和元件就要自己實作。過去在更新套件的時候需要確保 remark 和 rehype 的 plugin 能夠被整合,這也花上不少維護時間。於是後來我看了 Fumadocs,並且它預設的 plugin 和元件都符合我的需求。

Fumadocs 的優勢

Fumadocs 預設是專門做文件網站的,但由於它提供了多個 headless 的方法和元件,讓我快速建置了現在的部落格架構。我在後台寫文章,前台做編譯,並且裡面預設處理了 code block 和 table 的 syntax,大大減少了我的維護時間。

app/feed/page.tsx
import { compileMDX } from "@fumadocs/mdx-remote";

import { FumadocsComponents, V1MDXComponents } from "./mdx-components";

const Page = async ({
  params,
}: {
  params: Promise<{
    slug: string;
  }>;
}) => {
  const { slug } = await params;
  const content = await getContent(slug)
  //                    ^^^ get content from database
  const compiled = await compileMDX({
    source: content,
    components: {
      ...FumadocsComponents,
      ...V1MDXComponents,
    },
    mdxOptions: {
      remarkPlugins: [],
      rehypePlugins: [],
    },
  });
  return <compiled.body />
}

Fumadocs 的特色:

  • 不強制意見的框架:可以高度客製化,不會限制你的架構選擇
  • 預設豐富元件:包含 code block、table、callout 等常用元件
  • 優秀的開發體驗:提供 headless API,可以靈活整合到現有專案
  • 支援多種內容來源:可以從檔案系統、資料庫或 CMS 讀取內容

Drizzle

Drizzle 是我現在選定的 ORM 工具。它是直接透過 TypeScript 開發的,底層無需任何 engines 整合。由於打包後的大小才 31 KB,也適合在 serverless 的 server 運作。它支援 relational 和 SQL-like 的查詢方式。

const result = await db.query.users.findFirst({
  where: (users, { eq }) => eq(users.id, dto.userId),
	with: {
		posts: true			
	},
});
const result = await db
	.select()
	.from(countries)
	.leftJoin(cities, eq(cities.countryId, countries.id))
	.where(eq(countries.id, 10))

Drizzle Kit 的強大功能

除此之外 drizzle 透過 drizzle-kit 支援多種指令,像是 migraionstudio(資料庫管理介面)

pnpm drizzle-kit generate
pnpm drizzle-kit migrate
pnpm drizzle-kit push
pnpm drizzle-kit pull
pnpm drizzle-kit check
pnpm drizzle-kit up
pnpm drizzle-kit studio

Drizzle 的設計方式主打高效能和 typesafety,並提供 headless 的方式使用。由於它的使用方式較開放和自由,我認為 Drizzle 較適合給有 SQL 使用經驗的人使用。

從 Prisma 轉換的考量

其實一開始專案是使用 Prisma 來做 ORM 工具。我認為 Prisma 的使用體驗是非常好的,尤其是它在 schema 的定義較有可讀性,可以統一集中在 schema.prisma 檔案中。

generator client {
  provider = "prisma-client-js"
  previewFeatures = ["jsonProtocol"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  ADMIN
  USER
}

model Post {
  id          String   @id @default(cuid())
  slug        String   @unique
  title       String
  excerpt     String
  tags        String[]
  headImg     String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  readTime    Int?
  readingMins String?
  published   Boolean  @default(false)
  content     String
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId      String
}

除此之外,它的 query 寫法也易懂,同樣也支援多種指令來做資料庫的管理。

const result = await prisma
  .post
  .findMany({
    take: opts.input.take,
    skip: opts.input.skip,
    orderBy: { [opts.input.orderBy]: opts.input.sortOrder },
  })

版本說明:當初使用的 Prisma 版本還在 5.0.0 之前,那時候的 Prisma 底層還沒被改寫重構,並只支援一個 schema 檔案。

新版的架構已用 TypeScript 重構,並支援多個 schema 的寫法了。如果你現在考慮使用 Prisma,這些問題都已經得到改善。

轉換的原因

不過就因為當時只能寫一個 schema 檔,這也導致若我有多個 database 或 schema 要做串接會有困難,尤其是做多租戶架構(Multi-Tenancy)。除此之外,較大的問題我覺得是它底層的 Rust engines。對,我們每次運行的 prisma generate 會生成一個 binary code 在專案裡,這也導致過去在部署時很常遇問題,像是在 Vercel 上的 serverless function 過大而沒辦法部署。

於是後來我的專案就改用 Drizzle 做改寫。但我認為若你是後端新手,Prisma 可以比較快速地幫助上手;而如果你想要對資料庫的架構做優化,那你可以嘗試 Drizzle。

Drizzle vs Prisma 比較表

特性DrizzlePrisma
打包大小31 KB較大(包含 Rust engine)
查詢方式SQL-like 和 RelationalPrisma Client API
Schema 定義TypeScriptPrisma Schema Language
多 Schema 支援原生支援Prisma 5.0+ 支援prisma​
型別安全完全型別安全完全型別安全
學習曲線需要 SQL 基礎較容易上手
Serverless 友善度優秀(無 engine)良好(但有 engine)
客製化程度高(接近原生 SQL)中(透過 Prisma API)
適用對象有 SQL 經驗的開發者後端新手或追求快速開發

HeroUI(前身為 NextUI)

最後是我對 UI 的選擇。這個專案一開始就用 Tailwind CSS 做開發,不過起初並沒有什麼 UI 套件是針對 Tailwind 做開發的。過去需要自己實作很多 UI 元件。起初是 Shadcn UI,它用 Radix UI 加上 Tailwind 做開源的 UI 元件,我們可以直接看到原始碼並複製到專案裡及客製化更改 style 外觀。那時候我用 Shadcn UI 的架構寫了自己的 UI 套件來簡化調整 className 的時間,並且那時候他們還沒提供 CLI 做導入,所以我以套件的方式做導入(ChiaStack - 不過後來一直沒時間維護跟開發)。後來 HeroUI 出來並且更好地提供方案來解決我這幾個問題。

heroui

HeroUI 的特色

HeroUI(前身為 NextUI) 底層就是用 Tailwind 改寫的(起初 v1 是用他們自己的 style 架構)。我們可以直接下 Tailwind 的 className 做客製化。除此之外,它底層的動畫是用 Motion 來寫的,我們也能自己下 motionProps 來改寫元件特效。

const Component = () => {
  return (
    <Button
      onPress={() =>
        startTransition(async () => {
          await authClient.signIn.social({
            provider: Provider.google,
            callbackURL: getCurrentDomain(),
          });
        })
      }
      isLoading={isPending}
      variant="flat"
      color="primary"
      isIconOnly
      className="mb-5 mt-auto h-12 w-1/2 p-2"
    >
      <Icon icon="flat-color-icons:google" width={28} />
    </Button>
  )
}

並且它預設的 style 也是我很喜歡的外觀,於是後來就果斷導入,加快我的前後台介面開發。

heroui-components

HeroUI 的優勢:

  • 基於 Tailwind CSS:可以直接使用 Tailwind 的所有功能
  • 內建動畫系統:使用 Motion 提供流暢的動畫效果
  • 豐富的元件庫:提供完整的 UI 元件,包含 Button、Modal、Table 等
  • 高度客製化:可以透過 className 和 motionProps 深度客製化
  • 現代化設計:預設的設計風格現代且美觀
  • TypeScript 支援:完整的型別定義

總結與展望

這一年的重構之旅,讓我對全端開發有了更深入的理解。從 Turborepo 的 monorepo 架構、tRPC 的型別安全、Hono 的輕量高效,到 Better Auth 的安全機制、Fumadocs 的文件處理、Drizzle 的 SQL 控制,以及 HeroUI 的快速開發,每個工具的選擇都是基於實際需求和開發體驗的考量。

關鍵收穫

在這個過程中,我學到了幾個關鍵原則:

  1. 優先考慮開發體驗(DX):工具應該幫助我們更快完成工作,而不是增加負擔。例如從 Nest.js 轉向 Hono 就是為了解決 ES Module 相容性問題。
  2. 保持彈性和可擴展性:monorepo 架構讓我能輕鬆管理多個專案並共享程式碼,避免了重複複製貼上的困擾。
  3. 選擇合適的抽象層級:有時候接近底層(如 Drizzle)能獲得更好的控制,有時候高階抽象(如 Prisma)能加快開發。要根據專案需求和團隊經驗做選擇。
  4. 持續學習與調整:技術棧不是一成不變的,要根據專案需求適時調整。例如從 Auth.js 轉向 Better Auth 就是為了獲得更好的客製化能力。

未來計畫

未來我希望繼續擴展這個專案:

  • 整合 Side Projects:把之前的小工具 side projects 整合進入 monorepo,統一管理和維護
  • 優化 CI/CD:進一步提升部署效率,縮短從開發到上線的時間
  • 持續記錄:繼續維護並記錄開發過程,分享更多實戰經驗

Written by: Chia1104 CC BY-NC-SA 4.0

Chia1104
©
Chia1104