這篇會用一個 mid level 的視角來分享我眼中良好的 Next.js 網頁架構原則,作者尚未到 Architect 的程度,希望未來技術成長到那一天時,Next.js 仍然受主流歡迎,屆時會記得回來更新。一個好的架構設計不是為了好看,是為了讓下週的自己不會大罵:幹 @#%# 我上禮拜這是什麼鬼。今天我們聊怎麼設計 Next.js 項目的檔案結構。
Colocation: 把相關的東西放在一起
一個新手常見的錯誤是按檔案類型分類,所有組件放一個資料夾,所有工具函數放另一個。
src/
components/
ProductCard.tsx
ProductList.tsx
CartButton.tsx
utils/
formatPrice.ts
validateProduct.ts
hooks/
useCart.ts
useProduct.ts
types/
product.ts
cart.ts這樣的結構看起來很整齊,但實際上沒用。ProductCard 用 formatPrice、useCart、product.ts,這些檔案散落在四個地方。改 ProductCard 時要同時改五個檔案,更好的做法是 Colocation,把相關的檔案放在一起。
app/
products/
[id]/
page.tsx # 詳情頁
ProductDetail.tsx # Server Component
layout.tsx
ProductCard.tsx # Card 組件
useProduct.ts # Hook
types.ts # Types
page.tsx # 列表頁
layout.tsxProductCard 所有相關的東西都在 products 資料夾。需要修改時可以很快找到所有需要的檔案,新來的工程師也能快速理解「products 資料夾裡是所有和產品相關的邏輯」。
這個原則叫做 colocation,就是把功能相關的東西放在一起。大型專案裡,這個決策能減少一半的溝通成本。
核心業務邏輯單獨抽出
但 colocation 有個限制。當多個功能都用同一個工具函數,你該怎麼放?比如 formatPrice 被 ProductCard 和 OrderItem 都用。這時候不該 colocation,應該把這種共享邏輯單獨放一個地方。
app/
lib/
formatting/
formatPrice.ts
formatDate.ts
formatCurrency.ts
validation/
validateEmail.ts
validateProduct.ts
database/
queries.ts # Shared DB queries
schema.ts
products/
ProductCard.tsx
useProduct.ts
orders/
OrderItem.tsx
useOrder.tslib 資料夾是專門放共享邏輯,但不要把所有東西都扔進去。如果某個工具只被一個功能用,還是應該 colocation。
判斷標準很簡單:這個檔案被多少個地方 import?一個就 colocation,兩個以上就丟進 lib。
環境變數管理
很多人的環境變數長這樣。
.env.local
NEXT_PUBLIC_API_URL=http:
API_KEY=secret123
DATABASE_URL=postgres:
這樣其實也不太對。環境變數很容易出錯,型別也沒保護。你改了一個環境變數的名稱,JavaScript 代碼裡仍然用老名稱,只有到了生產環境才會炸。應該在一個地方集中驗證和類型化所有環境變數比較好。
const envSchema = {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
API_KEY: process.env.API_KEY,
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
}
type Env = typeof envSchema
const env: Env = {
NEXT_PUBLIC_API_URL: envSchema.NEXT_PUBLIC_API_URL,
API_KEY: validateRequired(envSchema.API_KEY, 'API_KEY'),
DATABASE_URL: validateRequired(envSchema.DATABASE_URL, 'DATABASE_URL'),
JWT_SECRET: validateRequired(envSchema.JWT_SECRET, 'JWT_SECRET'),
}
function validateRequired(value: string | undefined, name: string): string {
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
export default env然後在 code 裡用 env 物件。
import env from '@/lib/env'
export async function GET() {
const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/products`, {
headers: {
Authorization: `Bearer ${env.API_KEY}`
}
})
return response
}好處是什麼?TypeScript 會提示你所有可用的環境變數,改錯了名稱編譯時就會爆。啟動時如果缺少必需的環境變數,會立刻失敗,而不是等到運行時才炸。
平鋪 vs 分層
小項目用平鋪 app 資料夾的結構。
app/
page.tsx
layout.tsx
products/
page.tsx
[id]/page.tsx
orders/
page.tsx
api/
cart/route.ts
orders/route.ts每個業務模組一個資料夾,資料夾裡放 page.tsx、layout.tsx、components、hooks、types。
但當專案變大,比如有 20+ 個頁面,這個結構會很容易變亂。這時候考慮分層。
app/
(marketing)/ # 行銷頁面
page.tsx
about/page.tsx
blog/[slug]/page.tsx
(dashboard)/ # 需要登入的功能
layout.tsx # 檢查登入狀態
products/page.tsx
orders/page.tsx
(admin)/ # 管理員功能
layout.tsx # 檢查管理員權限
users/page.tsx
analytics/page.tsx
api/
auth/route.ts
admin/route.ts(marketing)、(dashboard)、(admin) 是 Route Groups,用來組織相關的頁面,但不會影響 URL。不像 /products,Route Group 不會出現在 URL 裡。
這樣的好處是可以給不同的模組應用不同的 layout,dashboard 區塊的所有頁面都會經過 (dashboard)/layout.tsx,裡面可以檢查登入狀態。admin 區塊也是。
共享組件怎麼放
共享 UI 組件放在 components 資料夾是合理的。但要分類。
components/
ui/ # 無業務邏輯的基礎組件
Button.tsx
Input.tsx
Card.tsx
Modal.tsx
products/ # 產品相關的複合組件
ProductCard.tsx
ProductList.tsx
ProductFilters.tsx
common/ # 頻繁用到的複合組件
Header.tsx
Footer.tsx
Navigation.tsxui 裡面的組件完全沒有業務邏輯,只是樣式和互動,其他組件可以自由複用。
products 資料夾裡的組件和產品業務邏輯綁定,只在產品相關頁面用。
common 是在多個頁面出現的複合組件,比如導航欄。
資料庫查詢的組織
如果你用 Prisma 或其他 ORM,查詢邏輯也要集中管理。
lib/
db/
products.ts
orders.ts
users.ts
import { prisma } from '@/lib/prisma'
export async function getProduct(id: string) {
return prisma.product.findUnique({ where: { id } })
}
export async function getProducts(limit: number = 10) {
return prisma.product.findMany({ take: limit })
}
export async function createProduct(data: ProductInput) {
return prisma.product.create({ data })
}所有產品相關的查詢都在 products.ts。需要改查詢邏輯時,改一個地方就行。
然後在 Server Components 或 API Routes 裡直接用。
import { getProducts } from '@/lib/db/products'
export default async function ProductsPage() {
const products = await getProducts()
}實際的項目結構
綜合以上考慮,一個中等規模專案的結構應該是這樣,會是一個比較易讀好維護的結構。
app/
(marketing)/
page.tsx
about/page.tsx
layout.tsx
(dashboard)/
products/
page.tsx
[id]/page.tsx
ProductCard.tsx
useProduct.ts
orders/page.tsx
layout.tsx # 登入檢查
api/
auth/route.ts
products/route.ts
components/
ui/
Button.tsx
Input.tsx
common/
Header.tsx
lib/
db/
products.ts
orders.ts
formatting/
formatPrice.ts
env.ts
prisma.ts
public
避免的陷阱
不要建立過度深的資料夾結構。app/features/products/components/cards/ProductCard.tsx 這種太深了,改個檔案要點十次 WTF
不要把所有東西都 colocation,有時候確實需要集中管理,判斷標準是複用度。
不要把業務邏輯混在 UI 組件裡。如果組件有複雜的邏輯,提出去放在 hooks 或 lib 裡。
不要硬編碼環境變數,記得用 env 記得用 env 記得用 env !
我的建議
新專案一開始用簡單的平鋪結構,當頁面增長到 20 個頁面以上時,考慮加入 Route Groups。
共享邏輯馬上就 colocation,一開始就對,後面要複用了再提到 lib。
環境變數從第一天就用驗證的 env 物件。
下次來聊 TypeScript 在 Next.js 中的應用,以及如何最大化開發體驗。