这是我对之前关于如何使用Next.js构建多租户应用的文章的更新教程,网址为 https://medium.com/@gg.code.latam/how-create-a-multi-tenant-app-with-next-js-13-14-app-router-7a30fb5f8454。
简单总结:多租户应用程序是一种软件,其中单一的应用程序实例服务于多个租户。每个租户都有自己的数据空间和配置,确保每个租户数据的隐私和安全。
在子域的环境中,每个客户通过各自的特定子域访问应用程序的页面。比如说:
- 用户 A:
tenantA.example.com
- 用户 B:
tenantB.example.com
- 用户 C:
tenantC.example.com
这种方法允许在子域级别进行自定义设置,便于数据管理和隔离操作,并且在软件即服务(SaaS)应用程序中非常普遍。
在这次,我们将进一步推进我们的技术栈,不再使用Firebase作为后端服务,而是使用Supabase(一个免费的替代方案,它允许我们使用PostgreSQL并轻松地将其连接到我们的应用程序)。另外,我们将不再使用Vercel来管理子域,而是使用Cloudflare来管理我们的域名和DNS,并使用一个脚本来让localhost上直接无缝使用子域,而无需额外的端口配置。
注意:为了创建一个支持多租户的应用,其中每个“租户”都有自己的子域名,我们需要一个只有一个顶级域名的域名(例如,.com, .ar, .uy, .tech)。像 ‘myapp.vercel.app’ 这样的域名对我们来说是不适用的,因为我们需要创建的子域名是 ‘subdomain.myapp.vercel.app’,这是不允许的。
既然已经明确了这一点,我们就来开始项目的初始设置吧。
这次我使用的是 Next.js 14.2.5,按照以下步骤安装:npx create-next-app@latest,并配置使用 TypeScript、Tailwind CSS、ESLint 和 import 通配符(不包括 src 目录,但你可以按需使用)。安装完 Next.js 后,我们还需要做以下几件事:在 Supabase 上创建一个账户并设置服务器(特别注意,保存 Supabase 提供的项目密码!),以及在 Cloudflare 上创建一个账户并把你的域名委托给 Cloudflare(我们之后会将域名委托给 Vercel)。
该项目新仓库的链接:https://github.com/GGCodeLatam/next-multitenant-2024
好了,我们开始在 Next.js 项目里写代码吧。
我们先从安装 Prisma 以及所有依赖项开始吧:
npm i @prisma/client @neondatabase/serverless @prisma/adapter-pg @supabase/ssr @supabase/supabase-js pg
# 瑞典语注释:此命令用于安装项目所需的 npm 包
npm i -D prisma @types/node @types/pg concurrently cross-env http-proxy ts-node
# 安装依赖:prisma、@types/node、@types/pg、concurrently、cross-env、http-proxy、ts-node
安装了 Prisma 之后,让我们开始用 Prisma 吧:
# 启动 Prisma 的命令可以根据具体版本和使用场景有所不同,这里仅提供通用示例
例如,您可以使用以下命令来启动 Prisma:
npx prisma db push
npx prisma generate
npx prisma init
运行此命令以初始化 Prisma 项目。
这将在我们的项目中创建一个 prisma 文件夹,在该文件夹内我们将找到 schema.prisma 文件。在本教程中,我们将编辑此文件以定义租户模型,以便在我们的 Supabase 数据库中创建子域。
客户端生成器 {
提供程序 = "prisma-client-js"
}
数据源 {
提供程序 = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
模型定义 Tenant {
id String @id @default(生成唯一标识符())
name String
subdomain String @unique
createdAt DateTime @default(当前时间())
updatedAt DateTime @updatedAt
更新时间 DateTime @updatedAt
}
现在我们已经在数据库中设置了Tenant模型,接下来我们需要找所需的环境变量。Supabase已经提供了适用于Next.js 14的特定连接,因此我们将从那里获取所需的变量:它将为我们提供特定于我们框架(例如Prisma)和ORM的变量。我们需要将以下内容添加到.env文件中:
DATABASE_URL (数据库URL)
DIRECT_URL (直接URL)
NEXT_PUBLIC_SUPABASE_URL (公开的Supabase URL)
NEXT_PUBLIC_SUPABASE_ANON_KEY (公开的Supabase匿名密钥)
一旦我们拿到这些 Supabase 凭证,我们可以把它们放到 .env 文件里。在 GitHub 项目里,我已经放了一个相同的 .env.example 文件给你。
# 特定于 Next.js 的环境变量
NEXT_PUBLIC_API_URL="https://domain.ar/api"
# Node 环境
NODE_ENV="production"
# other translations remain unchanged
# 通过 Supavisor 连接池连接到 Supabase。
DATABASE_URL="postgresql://postgres.[projectname]:[password]@aws-0-us-west-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
# 直接连接到数据库,用于进行迁移。
DIRECT_URL="postgresql://postgres.[projectname]:[password]@aws-0-us-west-1.pooler.supabase.com:5432/postgres"
# Supabase 公共 URL 和匿名密钥
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
我们现在有了这些变量,可以开始创建模型了。
运行以下命令: `npx prisma generate`
现在,让我们将生成的表格直接移动到我们在Supabase上的项目中。
使用命令 `npx prisma migrate dev --name init` 来初始化数据库迁移
这应该会在控制台显示一条消息,说明它正在从 prisma/schema.prisma 加载模式文件,并告知这些信息会被送到哪里(这可能需要几分钟)。
现在 Supabase 已经有了这个结构,下一步是创建一些子域名,以便我们可以同时在本地和生产环境中测试我们的应用。让我们创建它们:
首先,我们在‘schema.prisma’所在的文件夹里新建一个叫做‘seed.mts’的文件。
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() { // 主函数,创建多个租户
await prisma.tenant.createMany({
data: [
{ name: 'Tenant 1', subdomain: 'tenant1' },
{ name: 'Tenant 2', subdomain: 'tenant2' },
{ name: 'Test', subdomain: 'test' },
],
})
}
main()
.catch((e) => { // 输出错误信息到控制台
console.error(e)
process.exit(1) // 退出进程
})
.finally(async () => { // 断开与数据库的连接
await prisma.$disconnect()
})
一旦我们拿到了这个文件,让我们编辑 package.json
,以运行用于将此信息发送到我们服务器的命令:
"prisma": {
"seed": "node --loader ts-node/esm prisma/seed.mts"
},
有了这行代码在我们的package.json文件中,我们就可以运行相应的命令:
# 代码段保持不变
运行下面的命令来初始化数据库:npx prisma db seed
完成这一步后,我们就可以将数据存储在 Supabase 中。现在,让我们为 Next.js 中的多租户应用创建结构。首先,我们需要创建子域名的路径和APIs,配置 next.config 文件,并在项目根目录中添加 middleware.ts 文件。
middleware.ts: 中间件.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: [
"/((?!api/|_next/|_static/|[\\w-]+\\.\\w+).*)",
],
};
export async function middleware(req: NextRequest) {
const url = req.nextUrl;
let hostname = req.headers.get("host") || '';
// 去掉存在的端口号
hostname = hostname.split(':')[0];
// 定义允许的域名(包括主域名和localhost)
const allowedDomains = ["tudominio.ar", "www.tudominio.ar", "localhost"];
// 检查当前域名是否在允许的域名列表中
const isMainDomain = allowedDomains.includes(hostname);
// 如果不是主域名,提取子域名
const subdomain = isMainDomain ? null : hostname.split('.')[0];
console.log('中间件: 域名:', hostname);
console.log('中间件: 子域名:', subdomain);
// 如果是主域名,放行请求
if (isMainDomain) {
console.log('中间件: 检测到主域名,放行请求');
return NextResponse.next();
}
// 处理子域名逻辑
if (subdomain) {
try {
// 使用fetch确认子域名是否存在
const response = await fetch(`${url.origin}/api/tenant?subdomain=${subdomain}`);
if (response.ok) {
console.log('中间件: 检测到有效的子域名,');
// 将URL重写为基于子域名的动态路由
return NextResponse.rewrite(new URL(`/${subdomain}${url.pathname}`, req.url));
}
} catch (error) {
console.error('中间件: 租户获取错误:', error);
}
}
console.log('中间件: 无效的子域名或域名,返回404响应');
// 如果以上条件都不符合,返回404响应
return new NextResponse(null, { status: 404 });
}
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
async rewrites() {
return [
{
source: '/:path*',
destination: '/:path*',
},
{
source: '/',
destination: '/api/tenant',
},
];
},
};
export default nextConfig;
app/api/租户管理/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
export async function GET(request: NextRequest) {
const subdomain = request.nextUrl.searchParams.get('subdomain')
console.log('API: 收到请求的子域:', subdomain)
if (!subdomain) {
console.log('API: 子域是必填的')
return NextResponse.json({ error: '子域是必填的' }, { status: 400 })
}
try {
const tenant = await prisma.tenant.findUnique({
where: { subdomain },
select: { id: true, name: true, subdomain: true }
})
console.log('API: 找到租户的信息:', tenant)
if (!tenant) {
console.log('API: 未找到相关租户')
return NextResponse.json({ error: '未找到相关租户' }, { status: 404 })
}
return NextResponse.json(tenant)
} catch (error) {
console.error('API: 获取租户信息时出错:', error)
return NextResponse.json({ error: '服务器内部错误' }, { status: 500 })
}
}
app/api/create-tenant/route.ts:创建租户API路由文件
// app/api/create-tenant/route.ts
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
export async function POST(request: NextRequest) {
try {
const { name, subdomain } = await request.json()
if (!name || !subdomain) {
return NextResponse.json({ error: '名称和子域都是必填项' }, { status: 400 })
}
const existingTenant = await prisma.tenant.findUnique({
where: { subdomain },
})
if (existingTenant) {
return NextResponse.json({ error: '该子域已存在' }, { status: 409 })
}
const newTenant = await prisma.tenant.create({
data: { name, subdomain },
})
return NextResponse.json(newTenant, { status: 201 })
} catch (error) {
console.error('创建租户时发生错误:', error)
return NextResponse.json({ error: '服务器内部发生了错误' }, { status: 500 })
}
}
app/[subdomain]/page.tsx:
import { notFound } from 'next/navigation'
import prisma from '@/lib/prisma'
export default async function SubdomainPage({ params }: { params: { subdomain: string } }) {
const { subdomain } = params
console.log('SubdomainPage: 正在渲染子域页面:', subdomain)
try {
const tenant = await prisma.tenant.findUnique({
where: { subdomain },
})
console.log('SubdomainPage: 正在获取租户:', tenant)
if (!tenant) {
console.log('SubdomainPage: 未找到租户,即将重定向到404页面')
notFound()
}
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<h1 className="text-4xl font-bold">欢迎来到 {tenant.name}</h1>
<p>这是一个为 {subdomain} 的多租户站点</p>
<pre>{JSON.stringify(tenant, null, 2)}</pre>
</div>
)
} catch (error) {
console.error('SubdomainPage: 在获取租户时出错:', error)
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<h1 className="text-4xl font-bold text-red-500">错误</h1>
<p>加载租户信息时出现错误。</p>
<pre>{JSON.stringify(error, null, 2)}</pre>
</div>
)
}
}
并在项目根目录下创建一个名为‘lib’的文件夹,并将这两个文件放入其中。
lib/prisma.ts:
import { PrismaClient } from '@prisma/client'
declare global {
var prisma: PrismaClient | undefined
}
const prisma = global.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') global.prisma = prisma
export default prisma
lib/tenants.ts:
import prisma from './prisma'
export async function getTenantBySubdomain(subdomain: string) {
if (process.env.NEXT_RUNTIME === 'edge') {
// 在Edge环境中,我们调用一个API
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/tenants/${subdomain}`)
if (!response.ok) {
throw new Error('获取租户信息失败')
}
return response.json()
} else {
// 在其他环境中,我们正常使用Prisma
return prisma.tenant.findUnique({
where: { subdomain },
})
}
}
export async function getAllTenants() {
// 检索所有租户
return prisma.tenant.findMany()
}
现在配置已经完成,让我们在本地运行测试我们的项目(在项目根目录创建一个名为 proxy-server.js 的文件)。
import httpProxy from 'http-proxy';
import http from 'http';
const proxy = httpProxy.createProxyServer({
ws: true,
xfwd: true
});
const NEXT_SERVER_PORT = 3000;
const PROXY_PORT = 8080;
const server = http.createServer((req, res) => {
const host = req.headers.host;
console.log(`代理: 收到针对主机 ${host} 的请求, 路径: ${req.url}`);
// 将所有请求转发到你的 Next.js 应用
proxy.web(req, res, {
target: `http://localhost:${NEXT_SERVER_PORT}`,
changeOrigin: false,
headers: {
'Host': host,
}
});
});
server.on('upgrade', (req, socket, head) => {
proxy.ws(req, socket, head, {
target: `ws://localhost:${NEXT_SERVER_PORT}`,
changeOrigin: false,
});
});
server.listen(PROXY_PORT, () => {
console.log(`代理服务器正在运行于 http://localhost:${PROXY_PORT}`);
console.log(`你现在可以通过子域名访问应用,例如: http://tenant1.localhost:${PROXY_PORT}`);
});
proxy.on('error', (err, req, res) => {
console.error('代理错误:', err);
if (res.writeHead) {
res.writeHead(500, {
'Content-Type': 'text/plain'
});
res.end('代理服务器出错了。');
}
});
proxy.on('proxyReq', (proxyReq, req, res, options) => {
console.log(`代理: 正在转发请求到: ${proxyReq.path}`);
});
proxy.on('proxyRes', (proxyRes, req, res) => {
console.log(`代理: 接收到来自代理的响应状态: ${proxyRes.statusCode}`);
});
在package.json中设置自定义的本地启动脚本内容。
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"proxy": "node proxy-server.js",
"dev:proxy": "跨环境设置环境变量 (cross-env) NODE_ENV=开发 development 并行运行 \"npm run dev\" \"npm run proxy\""
},
// 开发环境启动开发服务器和代理服务器
请注意:在构建时,它现在也会在构建 Next 之前先生成 Prisma 文件。
现在,有了它,我们可以用以下命令本地启动并运行该项目。
npm run dev:proxy
这将允许您直接使用8080端口,无需通过代理或其他中间设备。我们可以直接进入位于‘app’内的主页面(就像在Next.js中一样),并通过在浏览器中输入‘tenant1.localhost:8080’来访问与子域相关的一切内容。
现在我们来添加一个组件以直接在我们的应用中创建子域名。
components/Home.jsx:
'use client';
import React, { useState } from 'react';
export default function Home() {
const [name, setName] = useState('');
const [subdomain, setSubdomain] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setMessage('');
setError('');
try {
const response = await fetch('/api/create-tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, subdomain }),
});
if (response.ok) {
const data = await response.json();
setMessage(`成功创建租户:${data.name} (${data.subdomain})`);
setName('');
setSubdomain('');
} else {
const errorData = await response.json();
setError(errorData.error || '租户创建失败');
}
} catch (error) {
setError('创建租户时出错了');
}
};
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<h1 className="text-4xl font-bold mb-4">欢迎来到多租户应用的世界</h1>
<p className="text-xl mb-8">创建个新的租户开始使用吧。</p>
<form onSubmit={handleSubmit} className="w-full max-w-md">
<div className="mb-4">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="租户名称"
required
/>
</div>
<div className="mb-4">
<input
type="text"
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
placeholder="子域名:"
required
/>
</div>
<button type="submit" className="w-full">创建租户</button>
</form>
{message && (
<div className="mt-4">
<h2>成功了</h2>
<p>{message}</p>
</div>
)}
{error && (
<div variant="destructive" className="mt-4">
<h3>出错</h3>
<p>{error}</p>
</div>
)}
</div>
);
}
让我们把它加到首页吧:
import Image from "next/image";
import Home from "./components/Home";
export default function Page() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
<Home />
</div>
</main>
);
}
就这样,我们现在就能在本地和生产环境中创建子域。
最后一步就是部署了。只需将我们的代码库推送到 GitHub,然后在 Vercel 上部署。然后,在域名设置里,添加您的域名及其通配符(例如:*.example.com)。我建议使用 CloudFlare 进行 DNS 管理。从这里起,我们有了坚实的基础来构建一个多租户应用程序,并可以为许多客户提供包含域名的应用程序。希望这些信息对您有所帮助,您有任何问题都可以在评论中提出。
该项目新仓库的链接:https://github.com/GGCodeLatam/next-multitenant-2024