为了账号安全,请及时绑定邮箱和手机立即绑定

[2024]一步步教你如何使用Next.js 14、Supabase和Cloudflare来打造一个可扩展的SaaS平台

标签:
杂七杂八

这是我对之前关于如何使用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

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消