You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Next.js App Router多域名(多租户)站点:域名解析与路由逻辑的实现方案选型咨询

Next.js App Router多域名(多租户)站点:域名解析与路由逻辑的实现方案选型咨询

嘿,这个问题问得特别到位——多域名/多租户的Next.js App Router实现确实是个常见的架构决策点,我结合实际项目踩过的坑和行业实践,给你拆解下两种方案的优劣,再聊聊更适配大多数场景的混合模式:


一、现有两种方案的优劣势分析

方案A:Middleware中完成域名解析+路由跳转

优势

  • 路由体验更流畅:所有逻辑前置,用户不会先加载错误内容再跳转,完全避免“闪跳”问题
  • 逻辑集中管控:不管用户访问哪个路由,都统一在Middleware处理域名解析和分支校验,避免重复代码
  • 缓存复用更及时:解析结果存入Cookie后,后续所有请求都能直接复用,减少重复API调用

劣势

  • 边缘环境限制:Next.js Middleware运行在边缘节点,如果你的resolve-domain API部署在非边缘集群,跨网络调用会增加延迟甚至冷启动时间
  • 错误处理难度高:Middleware的响应能力有限,API调用失败时很难渲染友好的错误页面,只能返回简单的HTTP状态码或重定向
  • 逻辑耦合风险:把业务相关的分支判断和路由逻辑放在Middleware,会让这个“通用中间件”逐渐变得臃肿,不利于维护

方案B:Layout/Server Component中完成解析+路由

优势

  • 逻辑内聚性强:把域名解析和页面渲染所需的fetch business info请求合并,减少独立API调用,降低网络开销
  • 错误处理更灵活:在Server Component/Layout中可以直接渲染错误页面、重试按钮,甚至引导用户联系支持,用户体验更友好
  • 避开边缘限制:如果你的业务API和resolve-domain API同集群,合并请求能有效减少网络延迟

劣势

  • 可能出现无效渲染:用户会先加载初始路由的框架结构,再跳转,存在“闪跳”或内容闪烁的问题
  • 重复解析风险:如果用户在站点内导航到其他路由,若未缓存解析结果,可能需要重复调用resolve-domain API
  • 路由逻辑分散:如果多个路由需要分支校验,每个地方都要单独处理,不如Middleware集中管控高效

二、推荐方案:Hybrid 混合模式(Middleware轻量处理 + Server Component核心逻辑)

这是目前Next.js多租户场景下的行业标准实践,兼顾了两种方案的优势,同时规避了各自的短板:

核心思路

  1. Middleware只做轻量辅助:捕获域名并传递,同时校验已有缓存,避免无效解析
  2. Server Component/Layout做核心逻辑:合并域名解析与业务数据获取,完成路由判断与跳转,同时处理错误和缓存

具体实现步骤

1. Middleware 层(app/middleware.ts)

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const { cookies, nextUrl } = request
  const businessToken = cookies.get('business-token')

  // 已有缓存的业务标识,直接放行,跳过所有处理
  if (businessToken) {
    return NextResponse.next()
  }

  // 把当前域名存入自定义请求头,方便Server Component直接读取
  const headers = new Headers(request.headers)
  headers.set('x-tenant-domain', nextUrl.host)

  // 同时存入临时Cookie作为降级方案(防止请求头被拦截)
  const response = NextResponse.next({ headers })
  response.cookies.set('temp-domain', nextUrl.host, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 15 * 60, // 15分钟过期,避免长期留存
  })

  return response
}

// 仅在需要处理的路由生效,排除静态资源、API等请求
export const config = {
  matcher: ['/', '/:locale/(.*)', '/select-branch'],
}

2. 根Layout 层(app/[locale]/layout.tsx,Server Component)

import { redirect, cookies, headers } from 'next/navigation'
import { getBusinessByDomain, getBusinessDetails } from '@/lib/api'

export default async function RootLayout({
  children,
  params: { locale },
}: {
  children: React.ReactNode
  params: { locale: string }
}) {
  // 1. 优先复用已缓存的业务标识
  const businessToken = cookies().get('business-token')?.value
  const branchId = cookies().get('branch-id')?.value

  if (businessToken) {
    try {
      // 获取业务详情(本来就要做的渲染逻辑)
      const business = await getBusinessDetails(businessToken, branchId)
      // 可以通过React Context把业务数据传递给子组件
      return (
        <html lang={locale}>
          <body>
            {/* 渲染对应租户的页面内容 */}
            {children}
          </body>
        </html>
      )
    } catch (err) {
      // 缓存失效,清除Cookie后重新解析
      cookies().delete('business-token')
      cookies().delete('branch-id')
    }
  }

  // 2. 无缓存时,获取当前域名并解析
  const domain = headers().get('x-tenant-domain') || cookies().get('temp-domain')?.value
  if (!domain) {
    return (
      <html lang={locale}>
        <body className="flex items-center justify-center h-screen">
          <div className="text-center">
            <h2 className="text-xl font-bold mb-2">无法识别当前域名</h2>
            <p className="text-gray-600 mb-4">请检查域名配置或联系管理员</p>
            <button onClick={() => window.location.reload()} className="px-4 py-2 bg-blue-500 text-white rounded">
              重试
            </button>
          </div>
        </body>
      </html>
    )
  }

  try {
    // 3. 调用域名解析API,同时合并业务详情请求(减少网络开销)
    const { token: newBusinessToken, isMultiBranch, defaultBranchId } = await getBusinessByDomain(domain)
    
    // 4. 缓存解析结果到Cookie
    cookies().set('business-token', newBusinessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      maxAge: 7 * 24 * 60 * 60, // 7天有效期
    })

    // 5. 处理分支选择逻辑
    if (isMultiBranch && !branchId) {
      // 跳转到分支选择页面
      redirect(`/${locale}/select-branch`)
    } else {
      // 缓存默认分支ID(如果存在)
      if (defaultBranchId) {
        cookies().set('branch-id', defaultBranchId, {
          httpOnly: true,
          secure: process.env.NODE_ENV === 'production',
          maxAge: 7 * 24 * 60 * 60,
        })
      }
      // 获取业务详情并渲染
      const business = await getBusinessDetails(newBusinessToken, defaultBranchId)
      return (
        <html lang={locale}>
          <body>
            {children}
          </body>
        </html>
      )
    }
  } catch (err) {
    return (
      <html lang={locale}>
        <body className="flex items-center justify-center h-screen">
          <div className="text-center">
            <h2 className="text-xl font-bold mb-2">服务器错误</h2>
            <p className="text-gray-600 mb-4">获取租户信息失败,请稍后重试</p>
            <button onClick={() => window.location.reload()} className="px-4 py-2 bg-blue-500 text-white rounded">
              重试
            </button>
          </div>
        </body>
      </html>
    )
  }
}

三、额外最佳实践

  1. 后端缓存优化:在你的resolve-domain API中,用Redis等缓存工具缓存domain->business的映射关系,减少数据库查询,提升响应速度
  2. 边缘缓存适配:如果使用Vercel等边缘平台,可以把域名解析结果缓存到边缘节点,进一步降低延迟
  3. 分支选择持久化:用户选择分支后,把branchId存入Cookie或localStorage,后续会话直接复用
  4. 路由安全校验:在Middleware或Layout中加入域名白名单校验,防止恶意域名访问你的站点

四、方案选型总结

  • 若你的resolve-domain API是边缘部署、且对路由体验要求极高 → 选择方案A
  • 若你的业务逻辑简单、且希望合并API请求减少开销 → 选择方案B
  • 大多数多租户场景下,混合模式是最优解:兼顾性能、体验和可维护性,也是目前行业内的标准实践

火山引擎 最新活动