Skip to content

Next.js 怎么按访客 IP 归属地跳转地区站?TypeScript 中间件实战

做多地区站点的团队迟早会碰到这个需求:大陆用户进 /cn,港台用户进繁体站,剩下的海外流量进 /global。理想状态是访客什么都不用点,落地就是自己该看的版本;现实是很多站首页放个「请选择地区/语言」的弹窗,用户嫌烦,SEO 那边还因为三个版本内容相似被判定重复页。

Next.js 的 Middleware 正好是干这个的地方——请求还没进页面就在边缘拦下来,读 IP、判断地区、重定向,页面代码一行都不用改。这篇用 TypeScript 写一套能直接上线的中间件,顺带说说这里最容易踩的两个坑:重定向循环爬虫被跳错站

一、为什么不用前端定位,也不用 IP 猜城市

两个反直觉的点先说清楚:

  • 别用浏览器定位:定位授权弹窗的拒绝率已经很高了,为了「显示哪个地区版本」让用户授权地图级位置,转化率直接崩。
  • 别指望 IP 精确到城市:IP 归属地是城市级数据,而且手机流量、企业专线、VPN 都会让结果漂移。判断「该给哪个地区站」这种国/地区级别的场景,是它最擅长的地方;想拿它做「离我最近的门店」,那得靠用户自己选或 GPS。

还有一个基础问题:Next.js 里怎么拿到客户端 IP。用 Vercel 之类的托管平台,x-forwarded-for 是平台写入的,可以直接用;自建 Nginx/网关的话,务必先配 real_ip_headerset_real_ip_from(只信任自己的代理),否则用户随手伪造一个 X-Forwarded-For 就能把自己「变成」香港用户。

二、方案设计

流程就四步:

  1. 拿 IP:从请求头取,拿不到就放行,别报错;
  2. 查归属地:调 https://ip9.com.cn/get?ip=<IP>,读 country_code 判断地区,读 prov/city 用于日志和后续运营;
  3. 写 cookie + 重定向:把判定结果写进 cookie(比如 24 小时),下次请求不再查接口——这一步很关键,中间件是每个请求都会跑的,不缓存就等于每次页面访问都打一次 IP 接口,免费版 60 次/分钟的量根本撑不住;
  4. 放行爬虫:Googlebot、Bingbot 的出口 IP 大多在美国,按 IP 跳转会把它们全甩到 /global,中文页从此收录受损。所以 UA 命中爬虫直接 next(),让每个地区版本有自己的可抓取 URL,再用 hreflang 互相声明。

三、TypeScript 实现(middleware.ts)

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

const IP9_API = 'https://ip9.com.cn/get?ip='
const REGIONS = ['cn', 'hk', 'global'] as const
type Region = (typeof REGIONS)[number]

/** 取客户端 IP:自建 Nginx 时用 real_ip_header 写入的 X-Real-IP,托管平台用 XFF 第一段 */
function clientIp(req: NextRequest): string {
  const xff = req.headers.get('x-forwarded-for') ?? ''
  const ip = req.headers.get('x-real-ip') ?? xff.split(',')[0] ?? ''
  return ip.trim()
}

/** 查 IP9 免费接口,必须给死超时:中间件超时比不跳转严重得多 */
async function locate(ip: string): Promise<{ country_code: string; prov: string; city: string } | null> {
  if (!ip) return null
  try {
    const res = await fetch(IP9_API + encodeURIComponent(ip), {
      signal: AbortSignal.timeout(800),
      cache: 'no-store',
    })
    if (!res.ok) return null
    const body = (await res.json()) as {
      ret: number
      data?: { country_code: string; prov: string; city: string }
    }
    return body.ret === 200 && body.data ? body.data : null
  } catch {
    return null
  }
}

function regionOf(countryCode: string): Region {
  const cc = (countryCode || '').toLowerCase()
  if (cc === 'cn') return 'cn'
  if (cc === 'hk' || cc === 'mo' || cc === 'tw') return 'hk'
  return 'global'
}

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl

  // 爬虫不参与跳转,否则中文页会被 Googlebot 的美国 IP 带去 global 站
  const ua = req.headers.get('user-agent') ?? ''
  if (/bot|spider|crawler|slurp|bingpreview|facebookexternalhit/i.test(ua)) {
    return NextResponse.next()
  }

  const cached = req.cookies.get('site_region')?.value as Region | undefined
  const current = REGIONS.find((r) => pathname === `/${r}` || pathname.startsWith(`/${r}/`))

  // 已判定地区且当前路径是对的 → 放行,这一步是防重定向循环的关键
  if (cached && current === cached) return NextResponse.next()

  let region: Region | undefined = cached
  if (!region) {
    const loc = await locate(clientIp(req))
    if (!loc) return NextResponse.next() // 查不到就不跳,用默认站兜底
    region = regionOf(loc.country_code)
  }

  const url = req.nextUrl.clone()
  const rest = current ? pathname.replace(`/${current}`, '') : pathname
  url.pathname = `/${region}${rest === '/' ? '' : rest}`

  const res = NextResponse.redirect(url)
  res.cookies.set('site_region', region, {
    maxAge: 60 * 60 * 24,
    path: '/',
    sameSite: 'lax',
  })
  return res
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api|.*\\..*).*)'],
}

允许用户手动切地区也很简单:切换链接指到 /cn/xxx,同时在服务端或前端把 site_region cookie 覆盖成目标值。只要 cookie 和路径一致,中间件就不会再把人弹回去——很多站点做地区跳转被用户吐槽,都是因为漏了「记住用户的手动选择」这一步。

四、几个容易翻车的地方

中间件是每次请求都跑。不带 cookie 的首次访问才查接口,这是性能底线。如果站点流量大、还想跨实例共享缓存,可以另外把结果塞进 Redis/KV,key 用 C 段(比如 1.2.3.0/24)来聚合同一个网段的访客,命中率会更高。

别在中间件里做重活middleware.ts 跑在 Edge Runtime,没有 Node 的 fs、没有本地内存缓存可以长期驻留,第三方 SDK 也别往里塞。就做「取 IP → 查归属地 → 决定跳不跳」三件事。

SEO 三件套要做到位:每个地区站有独立可抓取的 URL、页面里配 hreflang(含 x-default)、爬虫一律不跳。另外别用 302 把首页永久指到地区页,那会让搜索引擎很难判断你的主版本——常见做法是跳转后在首屏给一个显眼的切换入口,而不是把 URL 结构搞乱。

跨境 / 国内混合场景注意数据出境。IP 归属地查询本身很轻量,但把用户 IP 发给境外接口会牵出数据出境合规问题,国内业务优先用国内接口。IP9 的免费接口 https://ip9.com.cn/get?ip=<IP> 免注册免鉴权,返回国家、country_code、省市、运营商等字段,支持 IPv4/IPv6,查询在境内完成;需要在地区判断之外再加一层「机房 IP 拦截」的话,VIP 版提供 ip_type(ISP 家庭 / BUS 企业 / IDC 机房)和 ip_asn 字段。官网:https://www.ip9.com.cn

总结

Next.js 做地区跳转,记住四句话:cookie 记住判定结果、路径前缀防循环、爬虫一律放行、接口失败就兜底。代码加起来不到一百行,中间件层解决掉,页面里既不用写判断逻辑,也不用给用户弹「选择地区」的窗。