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

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

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

往 monorepo 架構開發至今已經兩年多的時間,從自己摸索到逐步掌握較複雜專案的開發,這是一個持續學習的過程。
過去與其他同事開發 side project 時,每當新開一個專案都需要把先前在其他專案用過的方法或元件複製到現有專案裡,這讓開案初期花費不必要的時間。這也是我更想嘗試 monorepo 的原因。
當然過程中也遇上不少問題,包含部署流程的調整及本地開發優化。例如透過 cache 的方式加快 lint 和 type check 的速度,這些都是實作後發現可以改進的地方。
最早這個專案打算以 T3 的架構去開發,而 tRPC 是其中一個核心工具,有助於前後端的 API 串接,並共享輸入和輸出的型別定義。
過去若是用 Next.js 的 API routes 開發 API,需要分別針對前後端去定義 API 的 input 跟 output 的型別和驗證。而且若後端的商業邏輯做了修改,前端也要確認是否需要修改並對應型別,相對非常耗時。
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" });
}
}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 的導入正好解決了這個問題。我只需要花時間維護後端的 service,前端直接 infer 到 service 的型別,剩下的錯誤處理 tRPC 也幫忙處理了。
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; "use client";
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/trpc";
export const api = createTRPCReact<AppRouter>({
abortOnUnmount: true,
}); "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 特別適合:
Hono 是一個輕量且可以同時在多個 runtime(Node、Bun、Deno)運行的 JavaScript 框架,寫法與 Express 類似。
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hono!'))
export default app它還支援 tRPC 和 Auth.js 等第三方 middleware,這幾個都是我當時正在使用的工具,正好方便做整合,於是後來就選擇用 Hono 來開發部分 API。
最早我開發的 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 的套件,需要非同步地載入進來,而且該方法一定要是非同步的。
@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 是一個專門應用在 TypeScript 專案的驗證工具,具備以下主要功能:
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 是我最後選定的登入驗證工具。選擇它的主要原因包括:
透過第二種儲存方式來存取 session,secondary storage
betterAuth({
// ... other options
secondaryStorage: {
// Your implementation here
}
})一開始自己是用 Auth.js。這個工具在 Next.js 專案中其實非常方便,只需要調整 config 的設定,前後端就有現成 API 可供使用。
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
export const { auth, handlers } = NextAuth({ providers: [GitHub] })export { auth as middleware } from "@/libs/auth"import { handlers } from "@/libs/auth"
export const { GET, POST } = handlers然而 Auth.js 本身實作了很多方法,若要做客製化功能會相對花時間。它預設的儲存模式只能選一種 adapter,若要把 Redis 和 PostgreSQL 共用會需要再寫一份 adapter。而且它沒有提供較為 headless 的方法,若實作在自己的後端,對應的 action 都只會以 Response 做回應,很難以 JSON 或物件去做其他 service 的整合。
const session = await auth.api.getSession({ headers: c.req.raw.headers });
// ^^^ 處理驗證取得 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.js | Better Auth |
|---|---|---|
| 框架支援 | 主要針對 Next.js 優化 | 支援多種框架(React、Vue、Svelte 等) |
| 客製化難度 | 較高(需要了解內部實作) | 較低(提供 headless API) |
| 儲存方案 | 單一 adapter | 支援雙儲存方案(如 Redis + PostgreSQL) |
| API 回應格式 | Response 物件 | JSON 物件 |
| 內建功能 | OAuth、Email 驗證 | OAuth、Email、MFA、Passkey、OIDC |
| 生態系成熟度 | 成熟且穩定 | 較新但功能完整 |
| 適用場景 | Next.js 專案,需要快速整合 | 需要高度客製化的全端專案 |
過去我也嘗試了幾個 SaaS 方案,不過後來想說既然都有自己的資料庫在儲存其他資料,那就自己簡單地實作登入功能。但這些方案我認為若要快速搭建 MVP 應用的話是很好的選擇。

Clerk本身提供了很多登入方式,並且在後台可以做管理跟設定,專案裡不需做額外的方法實作,可以直接用他們的 API 來完成專案其他功能。
除此之外,它還有現成的 UI 介面可以放到專案裡使用,並且支援很多語言。像是原生的手機應用(Kotlin 和 Swift)甚至 Expo 都有支援。若真的在做產品開發,Clerk 會是很好的選擇。
![]()
Logto 與 Clerk 類似,但值得一提的是 Logto 可以 self-host。若不想用這些 cloud 版本的可以嘗試自己部署,對於一些應用的專案整合上會比較好管理。
它同樣也支援 UI 介面,不過是導向 Logto 本身 host 的頁面。
過去就嘗試以 mdx 在寫文章,並搭配 next-mdx-remote。若文章是存在專案的資料夾裡,可以直接讀取出來再透過 Next.js 去 pre-build 這些頁面。
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 } }
}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 預設是專門做文件網站的,但由於它提供了多個 headless 的方法和元件,讓我快速建置了現在的部落格架構。我在後台寫文章,前台做編譯,並且裡面預設處理了 code block 和 table 的 syntax,大大減少了我的維護時間。
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 />
}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 透過 drizzle-kit 支援多種指令,像是 migraion 跟 studio(資料庫管理介面)
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 studioDrizzle 的設計方式主打高效能和 typesafety,並提供 headless 的方式使用。由於它的使用方式較開放和自由,我認為 Drizzle 較適合給有 SQL 使用經驗的人使用。
其實一開始專案是使用 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 | Prisma |
|---|---|---|
| 打包大小 | 31 KB | 較大(包含 Rust engine) |
| 查詢方式 | SQL-like 和 Relational | Prisma Client API |
| Schema 定義 | TypeScript | Prisma Schema Language |
| 多 Schema 支援 | 原生支援 | Prisma 5.0+ 支援prisma |
| 型別安全 | 完全型別安全 | 完全型別安全 |
| 學習曲線 | 需要 SQL 基礎 | 較容易上手 |
| Serverless 友善度 | 優秀(無 engine) | 良好(但有 engine) |
| 客製化程度 | 高(接近原生 SQL) | 中(透過 Prisma API) |
| 適用對象 | 有 SQL 經驗的開發者 | 後端新手或追求快速開發 |
最後是我對 UI 的選擇。這個專案一開始就用 Tailwind CSS 做開發,不過起初並沒有什麼 UI 套件是針對 Tailwind 做開發的。過去需要自己實作很多 UI 元件。起初是 Shadcn UI,它用 Radix UI 加上 Tailwind 做開源的 UI 元件,我們可以直接看到原始碼並複製到專案裡及客製化更改 style 外觀。那時候我用 Shadcn UI 的架構寫了自己的 UI 套件來簡化調整 className 的時間,並且那時候他們還沒提供 CLI 做導入,所以我以套件的方式做導入(ChiaStack - 不過後來一直沒時間維護跟開發)。後來 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 也是我很喜歡的外觀,於是後來就果斷導入,加快我的前後台介面開發。

這一年的重構之旅,讓我對全端開發有了更深入的理解。從 Turborepo 的 monorepo 架構、tRPC 的型別安全、Hono 的輕量高效,到 Better Auth 的安全機制、Fumadocs 的文件處理、Drizzle 的 SQL 控制,以及 HeroUI 的快速開發,每個工具的選擇都是基於實際需求和開發體驗的考量。
在這個過程中,我學到了幾個關鍵原則:
未來我希望繼續擴展這個專案: