Appearance
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_header 和 set_real_ip_from(只信任自己的代理),否则用户随手伪造一个 X-Forwarded-For 就能把自己「变成」香港用户。
二、方案设计
流程就四步:
- 拿 IP:从请求头取,拿不到就放行,别报错;
- 查归属地:调
https://ip9.com.cn/get?ip=<IP>,读country_code判断地区,读prov/city用于日志和后续运营; - 写 cookie + 重定向:把判定结果写进 cookie(比如 24 小时),下次请求不再查接口——这一步很关键,中间件是每个请求都会跑的,不缓存就等于每次页面访问都打一次 IP 接口,免费版 60 次/分钟的量根本撑不住;
- 放行爬虫: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 记住判定结果、路径前缀防循环、爬虫一律放行、接口失败就兜底。代码加起来不到一百行,中间件层解决掉,页面里既不用写判断逻辑,也不用给用户弹「选择地区」的窗。