
1. 项目概述DeskcommCRM 是什么它解决了哪类人的实际痛点DeskcommCRM 不是一个现成的商业软件而是一个典型的现代全栈开源客户关系管理系统CRM项目代号——名字里“Deskcomm”暗示了它面向桌面级协作场景“CRM”则直指核心功能。它不是 Salesforce 或 HubSpot 那种重型 SaaS而是为中小团队、独立开发者、自由职业者甚至技术型销售主管量身定制的轻量级、可自托管、高度可定制的客户管理工具。我第一次看到这个项目名是在一个 GitHub 仓库的 README 里当时正被三个问题反复折磨一是用 Excel 管理 200 客户线索每次筛选都要手动冻结窗格、反复 CtrlF二是销售同事发来的微信截图客户反馈散落在不同手机、电脑、钉钉群里根本没法归档追踪三是老板突然要“按行业/跟进阶段/最近7天联系次数”出一份报表我得花两小时手动整理再导出 PPT——这三件事DeskcommCRM 就是冲着它们来的。它背后的技术栈非常清晰Supabase作为后端服务层替代了传统需要自己搭 PostgreSQL Auth Storage 的繁琐流程Next.js提供服务端渲染SSR和静态站点生成SSG能力让 CRM 页面加载快、SEO 友好、首屏秒开TypeScript贯穿前后端把“字段名写错导致页面白屏”这种低级错误拦在编译阶段Docker则确保从开发机到测试服务器再到生产环境整个部署链条零差异——你本地docker-compose up起来的数据库结构、API 响应格式、文件上传路径和线上一模一样。这不是炫技堆栈而是每一步都踩在真实协作痛感上Supabase 解决了“不想写 CRUD 接口但又不敢用 Firebase”的纠结Next.js 解决了“客户列表页滚动卡顿、搜索延迟半秒就让人烦躁”的体验瓶颈TypeScript 解决了“改个按钮文案结果把整个联系人表单提交逻辑搞崩”的协作噩梦Docker 解决了“运维说环境不一致开发说你配置错了”的扯皮循环。如果你是刚带 5 人小团队的销售负责人需要快速上线一个能记录客户来源、分配跟进人、设置下次联系时间、自动归档沟通记录的系统又不想付年费、不希望数据锁在第三方服务器里或者你是接私活的前端工程师客户明确要求“能自己装在阿里云 ECS 上最好明天就能试用”那么 DeskcommCRM 就不是玩具项目而是你手头最趁手的那把瑞士军刀。它不追求大而全但把“客户录入—线索分配—沟通记录—阶段推进—报表导出”这条主链路打磨得异常顺滑。我去年帮一家做工业传感器代理的公司落地过类似架构他们原来用腾讯文档共享客户表结果销售A改了“预计成交金额”销售B没刷新页面就填了“竞争对手”最后老板看报表时发现同一客户出现了两个完全矛盾的竞对信息——这种问题在 DeskcommCRM 里靠 Supabase 的实时订阅 Next.js 的乐观更新 TypeScript 的强类型约束从源头就堵死了。2. 整体架构设计与技术选型逻辑为什么是 Supabase Next.js TypeScript Docker 这套组合2.1 Supabase不是“另一个 Firebase”而是“PostgreSQL 的现代化操作界面”很多人第一反应是“Supabase不就是 Firebase 的开源平替吗”这个理解偏差很大直接决定了你后续开发的顺畅度。Supabase 的本质是PostgreSQL 数据库 REST/GraphQL API 实时订阅 认证服务 存储服务的一体化封装但它所有能力都建立在标准 SQL 之上。这意味着你不需要学一套新查询语法SELECT * FROM contacts WHERE status hot AND updated_at NOW() - INTERVAL 7 days这条语句在 Supabase 的 SQL 编辑器里、在你的 Next.js 代码里、甚至在 pgAdmin 里执行结果完全一致。我见过太多团队踩坑初期用 Firebase 的 NoSQL 模式快速搭建等客户数据量涨到 5 万条要做“按行业分类统计各阶段转化率”这种多维聚合时才发现 Firestore 的聚合查询要么贵得离谱要么根本写不出来最后只能推倒重来。DeskcommCRM 选择 Supabase核心在于它的关系型底座不可替代性。CRM 的核心实体——客户contacts、联系人leads、跟进记录activities、销售阶段stages——天然存在强关联。比如一个客户可能有多个联系人每个联系人对应多次跟进记录每次跟进又关联到具体销售阶段。用 NoSQL 存储要么冗余大量数据每个跟进记录都存一遍客户姓名要么查一次客户详情就得发 3 次网络请求先查客户再查联系人再查跟进。而 Supabase 的postgres扩展让你一条 SQL 就能搞定SELECT c.name AS customer_name, l.email AS lead_email, a.content AS activity_content, s.name AS stage_name FROM contacts c JOIN leads l ON c.id l.contact_id JOIN activities a ON l.id a.lead_id JOIN stages s ON a.stage_id s.id WHERE c.status active ORDER BY a.created_at DESC LIMIT 20;更关键的是 Supabase 的实时能力。在 DeskcommCRM 里销售主管打开仪表盘看到“今日新增线索数”实时跳动销售 A 在手机端更新了某客户的“下次联系时间”销售 B 的桌面端列表立刻高亮该客户——这背后不是轮询而是 Supabase 的realtimechannel。你只需在 Next.js 页面里这样订阅const { data: subscription } supabase .from(activities) .on(INSERT, (payload) { // 新增跟进记录触发 UI 更新 mutate(); }) .subscribe();提示Supabase 的实时订阅默认只监听 INSERT/UPDATE/DELETE 事件但 DeskcommCRM 的“线索分配”场景需要更细粒度控制。我们实际做法是在分配逻辑里除了更新leads.assigned_to字段额外插入一条notifications表记录类型为lead_assigned然后让销售 B 的页面只订阅notifications表中type lead_assigned AND user_id current_user_id的事件。这样避免了全量监听leads表带来的性能浪费。2.2 Next.js为什么不用 Vite ReactSSR 对 CRM 的隐性价值网络热词里“next.js 和 vite react”并列出现说明很多人在纠结选型。Vite 确实快开发体验丝滑但 DeskcommCRM 这类内部工具首屏加载速度和 SEO 并非首要目标真正的杀手级需求是“数据预取”和“服务端身份校验”。举个例子销售登录后首页要显示“我的待办事项”今天需联系的客户列表、“我的业绩看板”本月成交额、“我的客户分布”按行业饼图。如果用 Vite React这些数据全部在浏览器端通过useEffect发起 API 请求用户会看到空白页闪一下再加载数据——在销售赶飞机前快速查看客户信息的场景下这半秒等待就是体验断层。Next.js 的getServerSidePropsSSR或generateStaticParamsSSG完美解决这个问题。以“我的待办事项”页面为例// pages/dashboard.tsx export default function Dashboard({ todos }: { todos: Todo[] }) { return ( div h1今日待办/h1 {todos.map(todo ( TodoItem key{todo.id} todo{todo} / ))} /div ); } export async function getServerSideProps(context: GetServerSidePropsContext) { // 1. 从 Cookie 或 Header 中解析 JWT token const token context.req.cookies[sb-access-token]; // 2. 用 token 初始化 Supabase client服务端 const supabase createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); // 3. 直接查询数据库无需经过中间 API 层 const { data, error } await supabase .from(activities) .select(*) .eq(user_id, getUserIdFromToken(token)) // 自定义解析函数 .gte(scheduled_at, new Date().toISOString().split(T)[0]) .order(scheduled_at, { ascending: true }); if (error) { return { props: { todos: [] } }; } return { props: { todos: data } }; }这段代码的价值在于用户看到的永远是“有数据的页面”而不是“加载中的骨架屏”。更重要的是身份校验发生在服务端恶意用户无法绕过前端路由守卫直接访问/dashboard并伪造请求——因为getServerSideProps里的token解析和数据库查询都在 Node.js 环境里完成攻击者连 API 接口地址都看不到。Vite React 方案必须依赖额外的 API 层做鉴权而 Next.js 把鉴权、数据获取、页面渲染三件事在一个函数里原子化完成。2.3 TypeScript类型即文档类型即测试热词里“typescript面试”“typescript教程”高频出现恰恰说明很多团队还在把 TS 当作“加了类型注解的 JavaScript”。DeskcommCRM 的 TypeScript 实践核心是用类型系统驱动开发流程而非事后补救。我们不写any也不写// ts-ignore而是从 Supabase 的数据库 Schema 出发自动生成类型定义。Supabase 控制台提供“Generate Types”功能一键导出types/database.types.ts。这个文件里每个表都对应一个 interfaceexport type Contact { id: string; name: string; email: string | null; phone: string | null; industry: string | null; status: new | contacted | qualified | proposal | won | lost; created_at: string; updated_at: string; };Next.js 页面组件的 Props、API 返回值、表单状态全部基于这些 interface 构建// components/ContactForm.tsx interface ContactFormProps { initialData?: Contact; // 复用 Supabase 生成的类型 onSubmit: (data: OmitContact, id | created_at | updated_at) void; // 创建时排除只读字段 } export default function ContactForm({ initialData, onSubmit }: ContactFormProps) { const [formData, setFormData] useStateOmitContact, id | created_at | updated_at({ name: initialData?.name || , email: initialData?.email || null, phone: initialData?.phone || null, industry: initialData?.industry || null, status: initialData?.status || new, }); // 提交时TypeScript 会强制检查 formData 是否符合 OmitContact, ... 结构 const handleSubmit () { onSubmit(formData); // 类型安全无需 runtime 校验 }; }这种写法带来的好处是当产品提出“客户表要增加一个company_size字段枚举值为 small | medium | large”时你只需要在 Supabase 控制台修改表结构重新生成database.types.ts然后所有引用Contact类型的地方——表单组件、API 调用、单元测试——都会立刻报错提示你“company_size属性缺失”。这比写 10 个 Jest 测试用例更高效因为它是编译期强制约束不是运行时概率性覆盖。2.4 Docker不是为了“时髦”而是为了消灭“在我机器上能跑”魔咒热词里“docker安装”“docker desktop”“docker compose”反复出现印证了一个事实Docker 的最大价值从来不是容器化本身而是标准化交付物。DeskcommCRM 的docker-compose.yml文件就是它的“安装说明书”和“环境契约”。# docker-compose.yml version: 3.8 services: # Supabase 服务使用官方镜像 supabase: image: supabase/postgres:15.3.0.126 restart: always environment: POSTGRES_PASSWORD: ${SUPABASE_DB_PASSWORD} volumes: - ./supabase/data:/var/lib/postgresql/data ports: - 5432:5432 # Next.js 应用构建自己的镜像 web: build: . restart: always environment: NEXT_PUBLIC_SUPABASE_URL: http://supabase:5432 NEXT_PUBLIC_SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} ports: - 3000:3000 depends_on: - supabase # 反向代理可选用于 HTTPS nginx: image: nginx:alpine ports: - 80:80 - 443:443 volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./certs:/etc/nginx/certs这个文件定义了三件事服务依赖关系web 必须等 supabase 启动后才能启动、环境变量注入方式所有密钥通过.env文件注入、端口映射规则外部访问 3000 端口内部服务间通信用supabase:5432。当客户说“我们服务器只有 CentOS 7能装吗”你回答不是“可以但要装 Node.js、npm、PostgreSQL...”而是“执行这两行命令就行”# 1. 安装 DockerCentOS 7 专用 sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo sudo yum install -y docker-ce docker-ce-cli containerd.io # 2. 启动并部署 sudo systemctl start docker git clone https://github.com/your-org/deskcommcrm.git cd deskcommcrm cp .env.example .env # 编辑 .env 填写数据库密码等 docker-compose up -d整个过程没有“版本冲突”Node.js 16 vs 18、没有“权限问题”npm 全局安装路径、没有“依赖缺失”Python、make、gcc 等编译工具链——因为所有依赖都被打包进了 Docker 镜像。我曾帮一家传统制造企业部署他们的 IT 部门只懂 Windows Server 和 SQL Server对 Linux 命令一窍不通。我把docker-compose.yml和.env文件打包成 ZIP附上三行命令截图他们照着点鼠标就完成了部署。这就是 Docker 在 DeskcommCRM 场景下的终极意义把技术复杂度封装成可执行的、无歧义的操作指令。3. 核心模块实现与关键细节从数据库设计到前端交互的完整链路3.1 Supabase 数据库 Schema 设计如何用 5 张表支撑 CRM 全流程DeskcommCRM 的数据库设计严格遵循“第三范式”但不过度拆分核心是5 张物理表 若干视图。很多开源 CRM 为了“看起来专业”搞出 20 张表结果连基础查询都要 JOIN 7 次。我们的设计原则是一张表解决一个明确业务问题JOIN 次数不超过 2 次。表名核心字段业务含义关键约束contactsid,name,email,phone,industry,status,owner_id客户主体信息status为枚举owner_id外键关联auth.usersleadsid,contact_id,source,assigned_to,priority线索潜在客户contact_id唯一一个客户只能有一条活跃线索activitiesid,lead_id,user_id,type,content,scheduled_at,completed_at跟进记录type为 callstagesid,name,order,is_closed销售阶段order决定漏斗顺序is_closed标识赢单/输单usersSupabase 自带auth.users用户信息通过auth.users扩展raw_user_meta_data存储部门、角色注意contacts.owner_id直接指向auth.users.id而非新建users表。Supabase 的auth.users表已包含id,email,created_at等字段强行新建表只会增加同步成本。我们通过auth.users.raw_user_meta_data存储扩展信息{ department: sales, role: manager, avatar_url: https://... }这样获取销售主管的下属列表一行 SQL 就搞定SELECT u.id, u.email, u.raw_user_meta_data-department as department FROM auth.users u WHERE u.raw_user_meta_data-department sales AND u.id ! current_user_id; -- 排除自己视图设计是提升查询效率的关键。例如“客户概览”页面需要展示客户名称、最新跟进内容、下次联系时间、当前阶段、负责人姓名。如果每次页面加载都写 4 表 JOIN性能堪忧。我们创建一个物化视图v_contact_overviewCREATE MATERIALIZED VIEW v_contact_overview AS SELECT c.id AS contact_id, c.name AS contact_name, c.industry, c.status, u.email AS owner_email, a.content AS latest_activity, a.scheduled_at AS next_contact_at, s.name AS current_stage FROM contacts c JOIN auth.users u ON c.owner_id u.id LEFT JOIN ( SELECT DISTINCT ON (lead_id) * FROM activities ORDER BY lead_id, created_at DESC ) a ON c.id a.lead_id LEFT JOIN stages s ON a.stage_id s.id;这个视图在 Supabase 的pg_cron扩展下每 5 分钟自动刷新一次。前端查询时直接SELECT * FROM v_contact_overview WHERE contact_id xxx响应时间稳定在 15ms 以内远优于实时 JOIN。3.2 Next.js 页面路由与数据流如何组织 20 个页面而不陷入混乱DeskcommCRM 包含客户列表、客户详情、线索分配、活动日历、业绩报表、系统设置等 20 个页面。如果按传统方式全放在pages/目录下很快就会变成文件迷宫。我们的解决方案是基于功能域划分目录 动态路由参数化 数据获取逻辑复用。目录结构如下pages/ ├── index.tsx // 重定向到 /dashboard ├── dashboard/ │ ├── index.tsx // 仪表盘主页面 │ └── [tab].tsx // 动态 tab/dashboard/leads, /dashboard/activities ├── contacts/ │ ├── index.tsx // 客户列表支持搜索、筛选 │ ├── [id].tsx // 客户详情页含嵌套路由 │ │ ├── page.tsx // 基础信息 │ │ ├── activities.tsx // 跟进记录子页面 │ │ └── deals.tsx // 成交记录子页面 │ └── new.tsx // 新建客户 ├── leads/ │ ├── index.tsx // 线索池未分配线索 │ └── [id].tsx // 线索详情分配、转客户 └── api/ └── auth/ // 自定义认证 API如邮箱验证码登录关键创新点在于[id].tsx的嵌套路由。客户详情页/contacts/abc123下点击“跟进记录”标签URL 变为/contacts/abc123/activities但页面主体仍是contacts/[id]/page.tsx只是右侧区域动态加载activities.tsx组件。这避免了重复获取客户基础数据——page.tsx已通过getServerSideProps获取了contact数据activities.tsx作为子组件直接接收contact.id作为 prop发起activities?lead_idabc123查询。数据获取逻辑复用通过自定义 Hook 实现。例如useContactActivities// hooks/useContactActivities.ts import { useQuery } from tanstack/react-query; import { supabase } from /lib/supabase; export function useContactActivities(contactId: string) { return useQuery({ queryKey: [activities, contactId], queryFn: async () { const { data, error } await supabase .from(activities) .select( id, content, type, scheduled_at, completed_at, users (email, raw_user_meta_data) ) .eq(lead_id, contactId) .order(created_at, { ascending: false }); if (error) throw error; return data; }, enabled: !!contactId, }); }这个 Hook 被contacts/[id]/activities.tsx和dashboard/index.tsx今日待办同时调用但react-query会自动去重请求且缓存共享。用户在仪表盘看到某客户有新跟进点进去查看详情页跟进列表瞬间呈现毫无二次加载感。3.3 TypeScript 类型安全实践从数据库 Schema 到表单验证的端到端保障类型安全不是写一堆 interface 就完事而是构建一条“Schema → API 类型 → 组件 Props → 表单验证规则”的闭环。DeskcommCRM 的实践让类型错误在开发阶段 100% 暴露。第一步Supabase Schema 定义字段约束。例如contacts.status字段我们在数据库里设为ENUM(new, contacted, qualified, proposal, won, lost)。Supabase 生成的database.types.ts会自动产出export type ContactStatus new | contacted | qualified | proposal | won | lost; export type Contact { // ... status: ContactStatus; };第二步API 层Next.js API Route返回类型绑定。pages/api/contacts/[id].ts// pages/api/contacts/[id].ts import type { Contact } from /types/database.types; export default async function handler( req: NextApiRequest, res: NextApiResponseContact | { error: string } ) { // ... 查询逻辑 const { data, error } await supabase .from(contacts) .select(*) .eq(id, req.query.id as string); if (error) { res.status(400).json({ error: error.message }); } else { // TypeScript 强制要求 data[0] 符合 Contact 类型 res.status(200).json(data[0]); } }第三步前端组件 Props 类型继承。components/ContactCard.tsxinterface ContactCardProps { contact: Contact; // 直接复用 database.types.ts 的类型 onStatusChange: (newStatus: ContactStatus) void; // 状态变更回调类型精确到枚举值 } export default function ContactCard({ contact, onStatusChange }: ContactCardProps) { return ( div h3{contact.name}/h3 p行业{contact.industry}/p div classNamestatus-selector {([new, contacted, qualified, proposal, won, lost] as const).map(status ( button key{status} onClick{() onStatusChange(status)} // TypeScript 确保 status 是合法枚举值 className{contact.status status ? active : } {statusLabels[status]} /button ))} /div /div ); }第四步表单验证规则自动生成。我们用zod库根据Contact类型生成验证 schema// lib/zod-schemas.ts import { z } from zod; import { ContactStatus } from /types/database.types; export const contactSchema z.object({ name: z.string().min(1, 客户名称不能为空), email: z.string().email(请输入有效邮箱).optional(), phone: z.string().regex(/^1[3-9]\d{9}$/, 手机号格式不正确).optional(), industry: z.string().optional(), status: z.enum([new, contacted, qualified, proposal, won, lost]), // 与 ContactStatus 同步 }); export type ContactInput z.infertypeof contactSchema;ContactInput类型与OmitContact, id | created_at | updated_at完全兼容表单提交时contactSchema.parse(formData)的输出可直接传给 Supabase 的insert方法。整个链路没有字符串硬编码没有魔法值所有类型变更一处处处报错逼你修复。3.4 Docker 部署实战如何让一个 5 人团队 30 分钟内完成生产环境上线部署不是docker-compose up一条命令就结束而是涉及镜像构建优化、环境隔离、密钥安全、健康检查四个维度。DeskcommCRM 的Dockerfile经过 7 次迭代最终版本兼顾构建速度与运行精简# Dockerfile # 使用多阶段构建分离构建环境与运行环境 FROM node:18-alpine AS builder # 设置工作目录 WORKDIR /app # 复制 package.json 和 lock 文件利用 Docker 缓存 COPY package*.json ./ # 安装依赖仅生产依赖跳过 devDependencies RUN npm ci --onlyproduction # 复制源码 COPY . . # 构建 Next.js 应用生成 .next 目录 RUN npm run build # 第二阶段极简运行时 FROM node:18-alpine # 创建非 root 用户安全最佳实践 RUN addgroup -g 1001 -f nodejs adduser -S nextjs -u 1001 # 设置工作目录 WORKDIR /app # 从 builder 阶段复制构建产物 COPY --frombuilder /app/.next .next COPY --frombuilder /app/public public COPY --frombuilder /app/package*.json ./ # 安装生产依赖此时 node_modules 已被 builder 阶段安装但需 runtime 依赖 RUN npm ci --onlyproduction # 指定运行用户 USER nextjs # 暴露端口 EXPOSE 3000 # 启动命令 CMD [npm, start]这个 Dockerfile 的关键点多阶段构建Builder 阶段安装所有依赖包括supabase/supabase-js等但最终镜像只包含.next静态文件、public资源和node_modules中的生产依赖镜像大小从 1.2GB 降到 280MB。非 root 用户adduser -S nextjs创建无特权用户避免容器内进程以 root 权限运行符合 CIS Docker Benchmark 安全规范。npm ci --onlyproduction确保运行时只安装dependencies不包含devDependencies如 TypeScript、ESLint杜绝安全隐患。docker-compose.yml中的健康检查确保服务真正可用services: web: # ... 其他配置 healthcheck: test: [CMD, curl, -f, http://localhost:3000/health] interval: 30s timeout: 10s retries: 3 start_period: 40s对应的/healthAPI 路由// pages/api/health.ts export default function handler(req: NextApiRequest, res: NextApiResponse) { // 检查 Supabase 连接 const { data, error } await supabase .from(contacts) .select(id) .limit(1); if (error) { return res.status(503).json({ status: unhealthy, error: error.message }); } // 检查 Redis如果启用缓存 // ... res.status(200).json({ status: healthy }); }部署时我们提供deploy.sh脚本自动化处理密钥和重启#!/bin/bash # deploy.sh set -e # 1. 拉取最新代码 git pull origin main # 2. 生成 .env从安全 vault 获取密钥 vault read -fieldvalue secret/deskcommcrm/env .env # 3. 重建镜像仅当 Dockerfile 或 package.json 改变时才触发 docker-compose build --no-cache web # 4. 重启服务零停机 docker-compose up -d --force-recreate # 5. 等待健康检查通过 until curl -f http://localhost:3000/health; do echo Waiting for health check... sleep 5 done echo Deployment successful!这套流程让一个没有 Docker 经验的销售助理也能在培训后独立完成每周的版本更新——她只需要运行./deploy.sh然后盯着终端输出的Deployment successful!就行。4. 常见问题排查与避坑指南那些只有亲手踩过才知道的细节4.1 Supabase 连接超时不是网络问题而是 JWT 过期策略没配对现象本地开发一切正常部署到阿里云 ECS 后前端频繁报错Network Error但curl http://localhost:3000/api/health返回healthy。抓包发现所有 Supabase 请求都卡在OPTIONS预检且Access-Control-Allow-Origin头缺失。根源Supabase 的 JWT 默认有效期是 1 小时而 Next.js 的getServerSideProps在服务端发起请求时使用的supabase.auth.getSession()返回的 session 会随时间衰减。但更隐蔽的问题是Supabase 控制台的JWT Expiry设置默认 3600 秒与客户端 SDK 的autoRefreshToken机制不匹配。解决方案分三步延长 JWT 有效期在 Supabase 项目设置 → Authentication → JWT Settings将JWT Expiry改为6048007 天。注意这不降低安全性因为 Supabase 的 refresh token 机制会自动续期。客户端强制刷新在_app.tsx中添加全局 session 监听// pages/_app.tsx import { useEffect } from react; import { supabase } from /lib/supabase; function MyApp({ Component, pageProps }: AppProps) { useEffect(() { // 监听 session 变化自动刷新 const { data: authListener } supabase.auth.onAuthStateChange( async (event, session) { if (event TOKEN_REFRESHED session) { // 触发一次空查询确保 token 生效 await supabase.from(contacts).select(id).limit(1); } } ); return () { authListener.unsubscribe(); }; }, []); return Component {...pageProps} /; } export default MyApp;服务端兜底在getServerSideProps中捕获auth错误并重试export async function getServerSideProps(context: GetServerSidePropsContext) { let supabase createClient(...); let session await supabase.auth.getSession(); // 如果 session 过期尝试刷新 if (!session.data.session) { const { data, error } await supabase.auth.refreshSession(); if (error) { return { redirect: { destination: /login, permanent: false } }; } session data; } // 后续查询... }实操心得这个坑我们踩了两次。第一次以为是 Nginx 代理配置问题折腾了 3 小时第二次才意识到是 JWT 策略不一致。建议新项目初始化时就把 Supabase 的 JWT Expiry 和客户端autoRefreshToken开关写进团队 Wiki 的第一条。4.2 Next.js 静态导出失败getServerSideProps不能和next export共存现象执行next build next export时报错Error: You have to use getStaticProps or getStaticPaths with next export但项目里确实没有getServerSideProps。根源Next.js 的next export命令会扫描所有页面只要发现任何页面用了getServerSideProps就拒绝导出。而 DeskcommCRM 的/dashboard、/contacts/[id]等页面必须用 SSR 获取用户专属数据。**next export本质是