為接案者打造輕量 CRM:Next.js + NestJS 全端開發紀錄
記錄開發一套面向自由工作者的客戶關係管理系統的過程,涵蓋認證設計、Pipeline 看板、活動追蹤、以及從需求分析到部署的完整全端開發經驗。
為什麼需要自己的 CRM
Salesforce 太大、HubSpot 太多功能用不到、Excel 又太簡陋。作為自由工作者或小型工作室,你需要的其實很簡單:
- 記錄客戶聯絡資訊
- 追蹤每個案子的進度(從洽談到結案)
- 留下每次溝通的紀錄
- 看一眼就知道目前有多少案子在跑
市面上的 CRM 通常是為銷售團隊設計的,對一人或小團隊來說太 heavy。於是決定自己做一個。
功能規劃
核心模組
1. 客戶管理
- 基本資料(姓名、公司、Email、電話、地址)
- 分類(個人/企業/政府機關)
- 來源標記(推薦/網路/活動)
- 自訂標籤(支援多色標記)
- 社群帳號(LINE、Facebook、Instagram)
2. 案件追蹤(Deal Pipeline)
- 案件名稱、金額、預估成案日
- 階段管理:潛在 → 聯繫中 → 提案 → 議價 → 成交/失敗
- 每個階段自動帶入預設成案機率
- 看板式拖拉介面
3. 活動紀錄
- 類型:通話、Email、會議、筆記
- 關聯到特定客戶或案件
- 時間軸展示
4. Dashboard
- 本月成交金額
- 各階段案件數
- 客戶來源分析
- 近期活動摘要
技術選型
| 層級 | 技術 | 原因 | |------|------|------| | Frontend | Next.js 15 (App Router) | SSR + 良好的 DX | | UI | shadcn/ui + Tailwind CSS | 快速建立一致的介面 | | 拖拉 | @dnd-kit | 輕量且無障礙支援好 | | 圖表 | Recharts | React 生態最成熟的圖表庫 | | Backend | NestJS 11 | 模組化架構、適合 API | | ORM | Prisma 6 | 型別安全、migration 管理 | | DB | PostgreSQL | 可靠、功能豐富 | | Auth | JWT (Access + Refresh Token) | 無狀態認證 | | 部署 | Docker Compose | 一鍵啟動 |
認證設計
雙 Token 機制
- Access Token:15 分鐘過期,存在記憶體
- Refresh Token:7 天過期,存在 httpOnly cookie
為什麼不用單一 token?
短效的 access token 降低了 token 洩漏的風險。即使被截獲,15 分鐘後就失效。Refresh token 只在需要換新 access token 時使用,減少暴露機會。
// NestJS Auth Service
async login(email: string, password: string) {
const user = await this.validateCredentials(email, password);
const accessToken = this.jwtService.sign(
{ sub: user.id, email: user.email },
{ expiresIn: '15m' }
);
const refreshToken = this.jwtService.sign(
{ sub: user.id },
{ expiresIn: '7d', secret: this.refreshSecret }
);
return { accessToken, refreshToken };
}
async refresh(refreshToken: string) {
const payload = this.jwtService.verify(refreshToken, {
secret: this.refreshSecret,
});
// 發新的 token pair
return this.generateTokens(payload.sub);
}
前端 Token 管理
// 自動在 access token 過期前刷新
async function fetchWithAuth(url: string, options?: RequestInit) {
let token = getAccessToken();
if (isTokenExpired(token)) {
token = await refreshAccessToken();
}
return fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${token}`,
},
});
}
資料模型設計
model User {
id Int @id @default(autoincrement())
email String @unique
password String
clients Client[]
tags Tag[]
deals Deal[]
}
model Client {
id Int @id @default(autoincrement())
userId Int
name String
company String?
email String?
phone String?
category Category @default(PERSONAL)
source String?
deletedAt DateTime? // 軟刪除
tags ClientTag[]
deals Deal[]
activities Activity[]
user User @relation(fields: [userId], references: [id])
}
model Deal {
id Int @id @default(autoincrement())
userId Int
clientId Int
title String
value Int // 案件金額
stage DealStage @default(LEAD)
probability Int // 成案機率 0-100
expectedClose DateTime?
activities Activity[]
client Client @relation(fields: [clientId], references: [id])
user User @relation(fields: [userId], references: [id])
}
enum DealStage {
LEAD // 潛在客戶 (10%)
CONTACT // 聯繫中 (25%)
PROPOSAL // 已提案 (50%)
NEGOTIATION // 議價中 (75%)
WON // 成交 (100%)
LOST // 失敗 (0%)
}
多租戶隔離
每個 query 都帶 userId 條件,確保使用者只看到自己的資料:
async findAllClients(userId: number) {
return this.prisma.client.findMany({
where: {
userId,
deletedAt: null, // 排除軟刪除
},
include: { tags: { include: { tag: true } } },
orderBy: { createdAt: 'desc' },
});
}
不用多資料庫或 schema 隔離——對這個規模的應用,WHERE userId = ? 足夠。
Pipeline 看板實作
前端:@dnd-kit 拖拉
'use client';
import { DndContext, closestCorners } from '@dnd-kit/core';
function PipelineBoard({ deals }: { deals: Deal[] }) {
const stages = ['LEAD', 'CONTACT', 'PROPOSAL', 'NEGOTIATION', 'WON'];
const handleDragEnd = async (event) => {
const { active, over } = event;
if (!over) return;
const dealId = active.id;
const newStage = over.id; // column id = stage name
// Optimistic update
updateDealLocally(dealId, newStage);
// 同步到後端
await updateDealStage(dealId, newStage);
};
return (
<DndContext collisionDetection={closestCorners} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto">
{stages.map(stage => (
<StageColumn
key={stage}
stage={stage}
deals={deals.filter(d => d.stage === stage)}
/>
))}
</div>
</DndContext>
);
}
階段變更自動更新機率
// Backend: 拖到新階段時自動更新 probability
async updateStage(dealId: number, userId: number, newStage: DealStage) {
const defaultProbabilities = {
LEAD: 10,
CONTACT: 25,
PROPOSAL: 50,
NEGOTIATION: 75,
WON: 100,
LOST: 0,
};
return this.prisma.deal.update({
where: { id: dealId, userId },
data: {
stage: newStage,
probability: defaultProbabilities[newStage],
},
});
}
軟刪除策略
客戶資料用軟刪除(設 deletedAt),案件用硬刪除。原因:
- 客戶軟刪除:客戶可能之後又回來合作,保留歷史紀錄有價值
- 案件硬刪除:失敗的案件保留意義不大,直接刪乾淨
async softDeleteClient(id: number, userId: number) {
return this.prisma.client.update({
where: { id, userId },
data: { deletedAt: new Date() },
});
}
async restoreClient(id: number, userId: number) {
return this.prisma.client.update({
where: { id, userId },
data: { deletedAt: null },
});
}
Docker Compose 一鍵開發
services:
api:
build: ./apps/api
ports: ["4000:4000"]
depends_on:
db: { condition: service_healthy }
environment:
DATABASE_URL: postgresql://user:pass@db:5432/crm
web:
build: ./apps/web
ports: ["3000:3000"]
environment:
NEXT_PUBLIC_API_URL: http://localhost:4000
db:
image: postgres:17-alpine
healthcheck:
test: ["CMD", "pg_isready"]
volumes:
- pgdata:/var/lib/postgresql/data
# Migration 服務:跑完就退出
migration:
build: ./apps/api
command: npx prisma migrate deploy
depends_on:
db: { condition: service_healthy }
migration 服務是一個跑完就退出的容器——確保 schema 是最新的,然後自動停止。
開發過程的取捨
不做的功能
- Email 整合:不自動讀信箱,太侵入且技術複雜度高
- 行事曆同步:可以手動記活動,不需要即時同步 Google Calendar
- 多人協作:目標是個人或極小團隊,不做權限管理
- 行動 App:RWD 就夠了
先做 MVP 再迭代
Sprint 1-2 先做到「能登入、能建客戶、能追案件」就上線。Dashboard 和匯出功能是之後的事。
結語
這個專案最有價值的經驗是「克制」——知道哪些功能不做比知道要做什麼更重要。一個功能完整但肥大的 CRM 沒人想用,一個只做好三件事的 CRM 反而能成為日常工具。
對自由工作者來說,CRM 不需要複雜。你需要的是一個「打開就能用、關掉不會忘」的簡單系統。
關於作者

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。