你是否曾经好奇过,像 Webflow、Notion 或 Hashnode 这样的平台是如何仅通过一个代码库来为成千上万的用户提供服务的——而这些用户每个都有自己的唯一网址呢?

答案就是多租户架构:这种架构允许一个应用程序动态地为许多不同的用户提供隔离的服务,通常是通过子域名来实现这一点的。

在这个教程中,你将使用 Next.js、Express 和 Prisma 从零开始构建一个多租户风格的作品集 SaaS 平台。每个注册的用户都会获得自己的作品集页面,这个页面会通过他们的子域名来提供服务——这些页面是即时生成的,由同一个后端服务器提供支持,并且数据也存储在同一个数据库中。

你将会构建以下内容:

  • 一个登录页面,用户可以在该页面填写表格来创建自己的作品集

  • 一个基于 Express 和 Prisma 的后端系统,该系统会将每个用户视为一个“租户”来进行管理

  • 一层 Next.js 中间件,用于检测子域名并动态路由请求

  • 一个基于 JSON 的模板系统,用于控制每份作品集中哪些内容会显示出来

  • 一个可用于生产环境的作品集页面,在开发阶段通过 name.localhost:3000 访问,在生产环境中则通过 name.yourdomain.com 访问

你可以在本教程末尾提供的 GitHub 仓库中找到完整的源代码。

目录

先决条件

在开始之前,请确保您具备以下条件:

  • 您的机器上已安装Node.js(版本18或更高)。

  • 对React、TypeScript和REST API有基本的了解。

  • 熟悉Prisma ORM框架(无需成为专家即可)。

  • 拥有像VS Code这样的代码编辑器。

您将使用Prisma Postgres作为数据库,因此无需在本地单独配置数据库服务器。Prisma会帮您处理连接字符串和适配器配置的相关事宜。

什么是多租户架构?

多租户架构是一种软件设计模式,在这种模式下,一个应用程序同时为多个用户提供服务——这些用户被称为“租户”,每个租户的数据都是相互隔离的,而且通常拥有自己的URL地址。

在本教程中,具体操作流程如下:

  1. 用户访问您的登录页面,并填写姓名、个人简介及技能信息。

  2. 您的Express后端会在数据库中创建一个新的租户记录,并根据用户的姓名生成一个唯一的URL地址。

  3. 浏览器会将用户重定向到他们的姓名.localhost:3000这个地址。

  4. Next.js中间件会检测到这个子域名,提取出对应的URL地址,并在内部将请求路径修改为/tenant/他们的姓名

  5. 租户页面会从API中获取该用户的数据,并显示其个人作品集。

关键在于:用户在浏览器中看到的URL地址始终不会发生变化——这种路径重写过程对用户来说是完全透明的。实际上,同一个Next.js应用程序正在动态地为所有租户提供服务。

如何设置后端环境

首先创建一个项目文件夹,其中包含用于后端和前端的独立目录:

mkdir portfolio-saas && cd portfolio-saas
mkdir portfolio-api portfolio-client

进入后端目录,然后初始化一个新的Node.js项目:

cd portfolio-api
npm init -y

如何安装依赖项

请安装TypeScript、Prisma以及相关的辅助包:

npm install typescript tsx @types/node --save-dev
npx tsc --init
npm install prisma @types/node @types/pg --save-dev
npm install @prisma/client @prisma/adapter-pg pg dotenv

如何为ESM模式配置TypeScript

打开tsconfig.json文件,将其内容替换为以下内容:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2023",
    "strict": true,
    "esModuleInterop": true,
    "ignoreDeprecations": "6.0",
    "types": ["node"]
  }
}

接着打开package.json文件,在其中添加"type": "module"这一条,以便支持ESM模式:

{
  "type": "module"
}

如何初始化Prisma

运行以下命令来初始化Prisma并生成相应的数据库架构配置:

npx prisma init --db --output ../generated/prisma

此命令会创建一个`prisma/schema.prisma`文件、一个包含数据库连接信息的`.env`文件,以及一个用于存储Prisma配置文件的文件夹。

如何定义Prisma的数据库架构

打开`prisma/schema.prisma`文件,将其内容替换为以下代码:

generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model Tenant {
  id         String   @id @default(uuid())
  slug       String   @unique
  name       String
  bio        String
  skills     String[]
  templateId String
  createdAt  DateTime @default(now())
}

model Template {
  id     String @id @default uuid())
  name   String
  config Json
}

model Post {
  id         String   @id @default(uuid())
  title      String
  content    String
  tenantSlug String
 createdAt  DateTime @default(now())
}

让我们来看看每个模型所代表的含义。

`Tenant`模型代表已经注册并创建了个人作品集的用户。`slug`字段是根据用户的名字生成的(例如,“John Doe”会生成“john-doe”),并且会被用作该用户的子域名。`templateId`字段将每位用户与控制其作品集布局的模板关联起来。

`Template`模型以JSON格式存储布局配置信息。这样一来,你就不需要将诸如“hero”或“skills”这样的页面结构直接编码到组件中,而是可以将它们保存在数据库里。这样,在为不同的模板添加或删除页面结构时,完全不需要修改任何组件代码。

`Post`模型的存在是为了未来的扩展性——你可以利用它让用户在其个人作品集中发布博客文章。

如何执行第一次数据库迁移

运行以下命令,根据生成的数据库架构创建相应的表结构:

npx prisma migrate dev --name init

此命令会创建数据库表结构,生成迁移脚本,并将这些变更应用到你的数据库中。执行完成后,你的Postgres数据库结构就会与Prisma定义的架构完全一致。

如何生成并使用Prisma客户端

如何生成Prisma客户端

运行以下命令,根据你的数据库架构生成一个类型安全的Prisma客户端:

npx prisma generate

每次修改数据库架构后,只需运行一次此命令即可。生成的客户端文件会保存在你之前配置的`../generated/prisma`文件夹中。

如何创建客户端实例

lib/prisma.ts文件中创建一个新的文件,并添加以下内容:

import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';

const connectionString = process.env.DATABASE_URL as string;

const adapter = new PrismaPg({ connectionString });
const prisma = new PrismaClient({ adapter });

export default prisma;

这个文件创建了一个可供整个后端代码引用的共享Prisma客户端实例。PrismaPg适配器会使用.env文件中指定的连接字符串,将Prisma与您的Postgres数据库连接起来。

如何初始化模板

在任何租户注册之前,您的系统数据库中至少需要有一个模板。您不必将布局相关的配置硬编码到组件中,而是可以将这些配置以JSON格式存储起来,并在运行时读取它们。

prisma/seed.ts文件中创建一个新的文件,并添加以下内容:

import prisma from '../lib/prisma';

async function main() {
  await prisma.template.create({
    data: {
      name: 'minimal',
      config: {
        theme: {
          primaryColor: '#6366f1',
          background: 'dark',
        },
        sections: {
          hero: true,
          about: true,
          skills: true,
          projects: true,
          blog: true,
          contact: true,
        },
      },
    },
  });

  console.log('模板已成功初始化。');
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

config字段会以JSON格式存储在Postgres数据库中。当某个租户的资料页面被加载时,前端会读取这些JSON数据来决定显示哪些内容。如果将模板中的hero: false设置为true,那么所有使用该模板的租户的页面都会隐藏“首页”部分——而无需对代码进行任何修改。

现在,请在package.json文件中添加一个用于初始化模板的脚本:

{
  "scripts": {
    "seed": "tsx prisma/seed.ts"
  }
}

运行该脚本:

npm run seed

现在,您的数据库已经准备好了一个默认模板,可以用于为新租户创建账户。

如何构建Express API

接下来,您将构建用于创建租户账户并检索其数据的后端API。

如何安装Express框架

npm install express cors
npm install -D @types/express @types/cors

如何创建服务器入口点

创建src/index.ts文件:

import app from './app';

const PORT = 8080;

app.listen(PORT, () => {
  console.log('服务器正在8080端口运行');
});

如何创建Express应用

创建文件src/app.ts:

import express from 'express';
import cors from 'cors';
import tenantRoutes from './routes/tenant.routes';

const app = express();

app.use(cors());
app.use(express.json());
app.use('/api', tenantRoutes);

export default app;

这个文件用于配置CORS,这样你的Next.js前端就可以与API进行通信,同时也能解析JSON格式的请求体,并将所有与租户相关的路由都挂载在/api路径下。

如何创建租户控制器

创建文件src/controllers/tenant.controller.ts:

import { Request, Response } from 'express';
import prisma from '../../lib/prisma';

export async function createTenant(req: Request, res: Response) {
  const { name, bio, skills } = req.body;

  if (!name || !bio || !skills) {
    return res.status(400).json({ error: '缺少必需字段' });
  }

  const slug = name.toLowerCase().replace(/\s+/g, '-');

  const template = await prisma.template.findFirst();
  if (!template) {
    return res.status(500).json({ error: '未找到模板' });
  }

  const tenant = await prisma.tenant.create({
    data: {
      slug,
      name,
      bio,
      skills,
      templateId: template.id,
    },
  });

  res.json({ slug: tenantslug });
}

export async function getTenant(req: Request, res: Response) {
  const slug = req.params Slug;

  if (!slug || typeof slug !== 'string') {
    return res.status(400).json({ error: 'slug参数无效' });
  }

  const tenant = await prisma.tenant.findUnique({
    where: { slug },
  });

  if (!tenant) {
    return res.status(404).json({ error: '未找到租户记录' });
  }

  const template = await prisma.template.findUnique({
    where: { id: tenant.templateId },
  });

  res.json({ tenant, template });
}

让我们来了解一下这个控制器的功能。

createTenant函数会从请求体中提取用户的名称、个人简介和技能信息。它会将名称转换为小写,并用连字符替换其中的空格——例如“Jane Smith”会被转换成jane-smith。随后,该函数会查找第一个可用的模板,并在数据库中创建相应的租户记录,同时通过templateId将新创建的租户与对应的模板关联起来。

getTenant函数会根据传入的slug参数来查询租户信息,并同时获取与该租户相关联的模板信息。这两份数据会被一起返回,这样前端就可以通过一次API调用来渲染租户的信息并应用正确的布局配置。

如何创建租户路由

创建文件src/routes/tenant.routes.ts:

import { Router } from 'express';
import { createTenant, getTenant } from '../controllers/tenant.controller';

const router = Router();

router.post('/tenants', createTenant);
router.get('/tenants/:slug', getTenant);

export default router;

您的API现在提供了两个接口:

POST   /api/tenants        — 创建新的租户
GET    /api/tenants/:slug  — 获取租户信息及其模板

如何启动服务器

将开发脚本添加到您的package.json文件中:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "seed": "tsx prisma/seed.ts"
  }
}

运行该脚本:

npm run dev

在终端中,您应该会看到“服务器正在8080端口上运行”的提示。这样,您的后端服务就已经准备好了。

如何创建Next.js前端应用

进入portfolio-client目录,然后创建一个新的Next.js项目。在安装器询问是否要使用Tailwind CSS时,请务必选择“是”:

cd ../portfolio-client
npx create-next-app@latest . --typescript --app --tailwind --eslint
npm install

由于create-next-app会自动配置Tailwind CSS,因此无需再进行任何额外设置。tailwind.config.ts文件以及globals.css中的@tailwind指令已经准备好了。

如何通过中间件实现子域名路由功能

这是多租户架构的核心所在。您需要一段代码,它在每个请求被处理之前运行,从URL中提取子域名信息,并将请求路径重写为正确的内部路由地址——而用户根本不会注意到URL发生了变化。

在根目录下创建一个名为proxy.ts的文件:

import { NextRequest, NextResponse } from 'next/server';

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const host = request.headers.get('host') ?? '';

  const hostname = host.split(':')[0];
  const parts = hostname.split('');

  // 开发环境:john.localhost:3000
  if (hostname.endsWith('localhost')) {
    const subdomain = parts[0];

    // 如果是localhost主域名,则直接加载首页
    if (subdomain === 'localhost') {
      return NextResponse.next();
    }

    // 如果请求路径以/tenant开头,则不再进行重写
    if (pathname.startsWith('/tenant')) {
      return NextResponse.next();
    }

    // 否则,将请求路径重写为正确的子域名格式
    return NextResponse.rewrite(new URL `/tenant/${subdomain}`, request.url));
  }

  // 生产环境:john.yourdomain.com
  if (parts.length > 2) {
    const subdomain = parts[0];

    if (subdomain !== 'www') {
      if (pathname.startsWith('/tenant')) {
        return NextResponse.next();
      }

      // 将请求路径重写为正确的子域名格式
      return NextResponse.rewrite(new URL `/tenant/${subdomain}`, request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)',
  ],
};

以下是这个代理服务器的具体工作流程,步骤如下:

它会读取每一个传入请求中的host头部信息,并通过.符号将其分割开来,从而提取出子域名。例如,对于一个地址为john.localhost:3000的请求,其子域名就是john;而直接发送到localhost:3000的请求,其子域名就是localhost本身——在这种情况下,代理服务器会原样传递该请求,因此页面能够正常加载。

一旦检测到子域名的存在,代理服务器会在内部将请求地址从/重写为/tenant/john。这种重写过程对浏览器来说是完全不可见的——用户在地址栏中看到的仍然是john.localhost:3000,但Next.js会将该请求路由到你的/tenant/[slug]页面上。

通过if (pathname.startsWith('/tenant'))这一判断条件,可以有效地防止无限循环的重写现象。如果没有这个条件,已经被重写的请求在再次经过中间件时还会被重新处理。

如何构建登录页面

PortfolioSaaS登录页面,背景为深色,页面上显示有用于输入姓名、个人简介和技能的空白表单

如何更新页面布局

打开app/layout.tsx文件并进行相应的修改:

import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';

const geistSans = Geist({
  variable: '--font-geist-sans',
  subsets: ['latin'],
});

const geistMono = Geist_Mono({
  variable: '--font-geist-mono',
  subsets: ['latin'],
});

export const metadata: Metadata = {
  title: 'Portfolio SaaS应用',
  description: '使用子域名来创建并托管你的作品集',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
        
      
    
  );
}

如何创建首页

创建app/page.tsx文件:

'use client';

import { useState } from 'react';

export default function Home() {
  const [name, setName] = useState('');
  const [bio, setBio] = useState('');
  const [skills, setSkills] = useState('');

  const handleSubmit = async () => {
    const res = await fetch('http://localhost:8080/api/tenants', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name,
        bio,
        skills: skills.split(',').map((s) => s.trim()),
      }),
    });

    const data = await res.json();
    window.location.href = `http://${dataslug}.localhost:3000`;
  };

  return (
    
  );
}

当用户提交表单时,会依次发生三件事。首先,一个POST请求会在你的数据库中创建一个新的租户账户。其次,API会返回生成的slug地址。最后,浏览器会将用户重定向到他们的子域名——their-name.localhost:3000——在那里,中间件会接管并展示他们的作品集页面。

如何构建租户的作品集页面

创建文件app/tenant/[slug]/page.tsx:

import type { Metadata } from 'next';

async function getTenant(slug: string) {
  const res = await fetch(`http://localhost:8080/api/tenants/${slug}`, {
    cache: 'no-store',
  });
  return res.json();
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<>Metadata> {
  const { slug } = await params;
  const { tenant } = await getTenantslug);

  if (!tenant) {
    return {
      title: '作品集未找到',
      description: '该作品集不存在。",
      robots: { index: false, follow: false },
    };
  }

  return {
    title: tenant.name,
    description:
      tenant.bio?.slice(0, 160) ||
      `探索${tenant.name}的专业作品集`,
    openGraph: {
      title: tenant.name,
      description: tenant.bio,
      type: 'website',
    },
  };
}

function initials(name: string) {
  return name
    .split(' ')
    .filter(Boolean)
    .slice(0, 2)
    .map((n) => n[0]?.toUpperCase())
    .join('');
}

export default async function TenantPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const data = await getTenant(slug);

  const tenant = data?.tenant;
  const template = data?.template;

  if (!tenant) {
    return (
      
作品集未找到
); } const primary = template?.config?.theme?.primaryColor || '#6366f1'; // 根据模板生成页面内容,同时提供默认值作为备用 const sections = { hero: true, about: true, skills: true, projects: true, blog: true, contact: true, ...(template?.config?.sections ?? {}), }; const avatarUrl = tenant.avatarUrl as string | undefined; return (
{tenant.name}</span>
{sections.contact & amp;& ( 联系我 )}
)}
{/* 正文 */}
{tenant.name}

; {tenant.bio}

联系我们 )} {sections.skills & amp;& ( 查看技能 )}
阅读博客 )}
发送邮件联系我 )}
}} {/* 技能 */} {sections.skills & amp;& (
技能
{skill} ))}
}} {/* 项目 */} {sections.projects & amp;& (
项目
{p} ))}
}} {/* 博客 */} {sections.blog & amp;& (
博客
{post} ))}
}} {/* 联系我们 */} {sections.contact & amp;& (
联系我们