用 Vercel 部署一个 Next.js + Postgres 的 SaaS Demo

0. 目标 & 架构概览 我们做一个极简 SaaS Demo: 技术栈: Next.js 14+(App Router) Postgres(云服务:Neon / Supabase / Vercel Postgres 均可) Prisma ORM 功能(极简版): workspace(租户)表 …

0. 目标 & 架构概览

我们做一个极简 SaaS Demo:

  • 技术栈:

    • Next.js 14+(App Router)
    • Postgres(云服务:Neon / Supabase / Vercel Postgres 均可)
    • Prisma ORM
  • 功能(极简版):

    • workspace(租户)表:workspaces

    • 用户表:users

    • 项目表:projects(挂在 workspace 下)

    • 提供几个 API:

      • 创建 workspace
      • 在指定 workspace 下创建 project
      • 查询某个 workspace 下的 project 列表

架构思路:

  • 所有业务表都带 workspace_id(或 tenant_id)→ 多租户基础。

  • Next.js API Route(或 Route Handler)里,从请求头 / 路径解析当前 workspace,然后查询时带上 where: { workspaceId } 即可。

  • 部署时:

    • Next.js 前后端都扔到 Vercel
    • Postgres 单独用一个云服务,暴露 DATABASE_URL,在 Vercel 的环境变量里配置。

1. 初始化 Next.js 项目

在本地建项目(假设目录名 saas-demo):

npx create-next-app@latest saas-demo \
  --typescript \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

进入目录:

cd saas-demo

本地先启动一下看是否正常:

npm run dev
# 或
pnpm dev

浏览器访问 http://localhost:3000,确认项目 OK。


2. 准备 Postgres(本地 or 云)

你可以选任意云 Postgres,这里用一个通用思路:

  1. 去 Neon / Supabase / Railway / Vercel Postgres 创建一个 Postgres 实例。
  2. 拿到一个标准的连接串,形如:
postgresql://USER:PASSWORD@HOST:PORT/DB_NAME?schema=public

先记下来,后面要塞进 .env 和 Vercel。

本地开发用 .env

cp .env.example .env  # 如果有
# 或直接创建 .env

写入:

DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/DB_NAME?schema=public"

提示:

  • 本地可以用 docker 跑一个 Postgres,线上换成云服务,只要 DATABASE_URL 一样即可。
  • Vercel 部署时再在项目的 Environment 里填同样的 DATABASE_URL

3. 接入 Prisma & 定义 SaaS 数据模型

安装 Prisma:

npm install prisma --save-dev
npm install @prisma/client

初始化 Prisma:

npx prisma init

这会生成 prisma/schema.prisma.env,逻辑类似。

修改 prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

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

// 多租户基础:Workspace + User + Project

model Workspace {
  id        String    @id @default(cuid())
  name      String
  slug      String    @unique        // 用于 URL / 子域
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  users     UserWorkspace[]
  projects  Project[]
}

model User {
  id        String            @id @default(cuid())
  email     String            @unique
  name      String?
  createdAt DateTime          @default(now())
  updatedAt DateTime          @updatedAt

  workspaces UserWorkspace[]
}

model UserWorkspace {
  id          String    @id @default(cuid())
  user        User      @relation(fields: [userId], references: [id])
  userId      String
  workspace   Workspace @relation(fields: [workspaceId], references: [id])
  workspaceId String
  role        String    // owner / admin / member
  createdAt   DateTime  @default(now())
}

model Project {
  id          String    @id @default(cuid())
  name        String
  description String?
  workspace   Workspace @relation(fields: [workspaceId], references: [id])
  workspaceId String
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

然后执行迁移,把表建到 Postgres:

npx prisma migrate dev --name init_saas_schema

本地成功后,你可以用 npx prisma studio 看下数据结构。

4. Prisma Client 封装(避免热重载多实例)

src/lib/prisma.ts 中创建单例(Next.js 热重载时防止多次实例化):

// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient | undefined };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: ["query", "error", "warn"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

5. 简单的 API:按 workspace 创建 & 列出项目

我们假设用路径 /api/workspaces/[slug]/projects

  • POST: 在某个 workspace 下创建一个 project
  • GET: 获取该 workspace 下的全部 projects

在 App Router 下创建:

src/app/api/workspaces/[slug]/projects/route.ts

// src/app/api/workspaces/[slug]/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// 简单的帮助函数:根据 workspace slug 获取 workspace
async function getWorkspaceBySlug(slug: string) {
  return prisma.workspace.findUnique({
    where: { slug },
  });
}

export async function GET(
  req: NextRequest,
  { params }: { params: { slug: string } }
) {
  const { slug } = params;
  const workspace = await getWorkspaceBySlug(slug);

  if (!workspace) {
    return NextResponse.json(
      { error: "Workspace not found" },
      { status: 404 }
    );
  }

  const projects = await prisma.project.findMany({
    where: { workspaceId: workspace.id },
    orderBy: { createdAt: "desc" },
  });

  return NextResponse.json({ projects });
}

export async function POST(
  req: NextRequest,
  { params }: { params: { slug: string } }
) {
  const { slug } = params;
  const workspace = await getWorkspaceBySlug(slug);

  if (!workspace) {
    return NextResponse.json(
      { error: "Workspace not found" },
      { status: 404 }
    );
  }

  const body = await req.json().catch(() => null) as {
    name?: string;
    description?: string;
  };

  if (!body?.name) {
    return NextResponse.json(
      { error: "name is required" },
      { status: 400 }
    );
  }

  const project = await prisma.project.create({
    data: {
      name: body.name,
      description: body.description ?? null,
      workspaceId: workspace.id,
    },
  });

  return NextResponse.json({ project }, { status: 201 });
}

再给一个创建 workspace 的简单接口:
src/app/api/workspaces/route.ts

// src/app/api/workspaces/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => null) as {
    name?: string;
    slug?: string;
  };

  if (!body?.name || !body?.slug) {
    return NextResponse.json(
      { error: "name and slug are required" },
      { status: 400 }
    );
  }

  const workspace = await prisma.workspace.create({
    data: {
      name: body.name,
      slug: body.slug,
    },
  });

  return NextResponse.json({ workspace }, { status: 201 });
}

这样你就有了最基本的多租户数据结构 + API。

6. 在页面里简单调用(前端 Demo)

例如在 src/app/[slug]/page.tsx 里根据 workspace slug 展示项目列表:

// src/app/[slug]/page.tsx
import { prisma } from "@/lib/prisma";

interface Props {
  params: { slug: string };
}

export default async function WorkspacePage({ params }: Props) {
  const workspace = await prisma.workspace.findUnique({
    where: { slug: params.slug },
  });

  if (!workspace) {
    return <div>Workspace not found</div>;
  }

  const projects = await prisma.project.findMany({
    where: { workspaceId: workspace.id },
    orderBy: { createdAt: "desc" },
  });

  return (
    <main style={{ padding: 24 }}>
      <h1>Workspace: {workspace.name}</h1>
      <h2>Projects</h2>
      <ul>
        {projects.map((p) => (
          <li key={p.id}>
            <strong>{p.name}</strong>
            {p.description && <span>  {p.description}</span>}
          </li>
        ))}
      </ul>
    </main>
  );
}

注意:这个页面是 服务器组件,直接在服务端用 Prisma 查询。
后面你可以逐步换成 Client 组件 + API 调用 + 状态管理等等。

7. 为 Vercel 部署做准备

7.1 Git 仓库

初始化 git 并推到 GitHub(或 GitLab / Bitbucket):

git init
git add .
git commit -m "Init SaaS demo"
git remote add origin git@github.com:yourname/saas-demo.git
git push -u origin main

7.2 Vercel 上创建项目

  1. 打开 Vercel Dashboard,点击 New Project

  2. 选择刚刚的 Git 仓库 saas-demo

  3. Vercel 会自动识别这是 Next.js App Router 项目,构建命令一般为:

    • Install command: npm install
    • Build command: npm run build
    • Output dir: .next

可以保持默认。

7.3 配置环境变量

在 Vercel 项目设置的 Environment Variables 里配置:

  • DATABASE_URL = 刚才的 Postgres 连接串

建议:

  • ProductionPreview 环境都配置同样的变量,或者至少 Production 先配好。
  • 如果你需要区分 dev / prod 数据库,就用 Vercel 的 Preview 环境指向测试 DB,Production 环境指向正式 DB。

7.4 运行时注意:Node.js 环境

Prisma / Postgres 需要 Node.js runtime,不要放到 Edge runtime 中。

  • App Router 的 page / layout 默认是 Node runtime(Server Components)。
  • Route Handler 默认也是 Node runtime,只要你没配置 export const runtime = "edge" 就行。
  • 如果之后你要用 Edge 中间件,就注意不要在 Edge runtime 直接使用 Prisma。

8. 首次部署 & 验证

配置好之后,在 Vercel 上点击 Deploy

  • 构建完成后,会得到一个生产地址,例如:
    https://saas-demo-yourname.vercel.app
  • 你可以用 Postman / curl 测试:
# 1. 创建 workspace
curl -X POST https://saas-demo-yourname.vercel.app/api/workspaces \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Corp", "slug":"acme"}'

# 2. 在 acme workspace 下创建 project
curl -X POST https://saas-demo-yourname.vercel.app/api/workspaces/acme/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"First Project","description":"Hello SaaS"}'

# 3. 获取 acme workspace 下项目
curl https://saas-demo-yourname.vercel.app/api/workspaces/acme/projects

浏览器访问:

  • https://saas-demo-yourname.vercel.app/acme
    就能看到刚才添加的项目列表。

9. 从 Demo 到“像样的 SaaS”的下一步

在上面的骨架上,你可以逐步加东西:

  1. 用户系统 & 登录

    • auth:NextAuth.js / Lucia / 自写 JWT / Clerk 等;
    • UserWorkspace 中控制用户对 workspace 的访问与权限。
  2. 更完整的多租户模型

    • 路由策略:

      • Path-based:/app/[workspaceSlug]/...
      • Domain-based:[workspaceSlug].your-saas.com(可利用 Vercel 的多域名 + 中间件解析 Host);
    • 每个请求先解析当前 workspace,然后把 workspace 信息注入到请求上下文。

  3. 计费 & 订阅

    • Stripe / Paddle 等;
    • Workspace 中增加 plan, billingStatus, seats 等字段。
  4. 观测与监控

    • 开启 Vercel Analytics;
    • 或接入 Sentry、Datadog 等。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页