Get a visitor's IP address and country in Next.js (15 and 16)

Most answers for "Next.js get user IP" still use request.ip and request.geo, which Next.js 15 removed, and middleware.ts, which Next.js 16 renamed. Here is what works now on Vercel, on your own server and behind Cloudflare, and which headers you can actually trust.

On this page Which header to trust

You want the visitor's country to pick a currency, pre-fill a form or show a regional notice. In Next.js the top search results still say:

ts
// Next.js 13/14 middleware.ts: no longer works
export function middleware(request: NextRequest) {
  const country = request.geo?.country
  const ip = request.ip
}

Two things changed:

  • Next.js 15 removed request.ip and request.geo from NextRequest (PR #68379). The PR notes that the values had to be provided by the hosting provider, and points Vercel users to geolocation() and ipAddress() from @vercel/functions.
  • Next.js 16 renamed Middleware to Proxy. The file is proxy.ts and the exported function is proxy. It defaults to the Node.js runtime, and npx @next/codemod@canary middleware-to-proxy . migrates old code.

So you now read the IP from a header, and the right header depends on where the app runs. Getting that wrong is a security bug, not just a wrong country.

Which header to trust

Whatever is in front of Next.js decides which header is reliable. Anything else is client input.

Where it runsClient IPCountry without an extra lookup
Vercelx-real-ip, or ipAddress(request) from @vercel/functionsx-vercel-ip-country, or geolocation(request)
Your server behind Nginx/Caddythe header your proxy sets, such as X-Real-IP $remote_addrnone: look it up
Behind CloudflareCF-Connecting-IP, if the origin only accepts CloudflareCF-IPCountry, if enabled
next start exposed directlynone: there's no trustworthy headernone

On Vercel this is safe because, per Vercel's request headers docs, Vercel overwrites X-Forwarded-For and does not forward external values, to prevent spoofing. x-real-ip holds the same value. On your own server nothing overwrites anything unless you configure it. A visitor can send x-real-ip: 1.2.3.4 and, if Next.js is reachable directly, your code will believe it.

On Vercel

Route Handler

ts
// app/api/whoami/route.ts
import { geolocation, ipAddress } from '@vercel/functions'

export function GET(request: Request) {
  const geo = geolocation(request)
  return Response.json({
    ip: ipAddress(request) ?? null,
    country: geo.country ?? null, // "GR"
    city: geo.city ?? null,
  })
}

@vercel/functions reads Vercel's headers for you. If you'd rather not add the package, read them directly. Note that x-vercel-ip-city is percent-encoded for non-ASCII names:

ts
const country = request.headers.get('x-vercel-ip-country')       // "DE"
const region = request.headers.get('x-vercel-ip-country-region') // "BY"
const rawCity = request.headers.get('x-vercel-ip-city')
const city = rawCity ? decodeURIComponent(rawCity) : null       // "München"
const timezone = request.headers.get('x-vercel-ip-timezone')     // "Europe/Berlin"

These headers aren't present in local development (next dev), so always have a fallback.

Server Component

headers() is async since Next.js 15:

tsx
// app/page.tsx
import { headers } from 'next/headers'

export default async function Page() {
  const h = await headers()
  const country = h.get('x-vercel-ip-country') ?? 'XX'
  return <p>Prices shown for: {country === 'XX' ? 'your region' : country}</p>
}

Reading headers makes the route dynamic, so it is rendered per request. If only a small part of the page depends on the country, keep that part in its own component behind a Suspense boundary, or move the decision into Proxy and pass the result along, as shown next.

Self-hosted: make one header trustworthy

On your own server, put a reverse proxy in front and have it set (overwrite) a header with the address it saw:

nginx
location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;   # overwrites anything the client sent
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Then bind Next.js to localhost so the proxy is the only way in:

bash
next start -H 127.0.0.1 -p 3000

Now x-real-ip is reliable, for the same reason it is on Vercel. If Nginx itself sits behind a load balancer or CDN, first restore the real address in Nginx with the realip module. Otherwise $remote_addr is the load balancer. Real client IP behind Cloudflare shows the Nginx config, and X-Forwarded-For spoofing explains the general rule.

Warning

Don't copy x-forwarded-for.split(',')[0] from old answers. The first entry is whatever the client sent. On Vercel it happens to be right because Vercel overwrites the header. Anywhere else it's spoofable.

Get the country anywhere: lookup in proxy.ts

Off Vercel there's no country header, so look the IP up. Do it once per visitor in proxy.ts, store the answer in a cookie, and pass it to the rest of the app in a request header:

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

export const config = {
  // Pages only: skip API routes, static files and images
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
}

const COOKIE = 'visitor_country'

// x-real-ip is set by Vercel, or by your own reverse proxy (see "Self-hosted" above).
// Never read it on a server that clients can reach directly.
function clientIp(request: NextRequest): string | null {
  return request.headers.get('x-real-ip')
}

async function lookupCountry(ip: string): Promise<string | null> {
  try {
    const url = `https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`
    const res = await fetch(url, { signal: AbortSignal.timeout(1500) })
    if (!res.ok) return null // 429 over the daily limit, 401 bad key...: treat as unknown
    const data = await res.json()
    return data.country_code && data.country_code !== 'No Data' ? data.country_code : null
  } catch {
    return null // timeout or network error: never block the page on geolocation
  }
}

export async function proxy(request: NextRequest) {
  const known = request.cookies.get(COOKIE)?.value
  const ip = clientIp(request)
  const country = known ?? (ip ? await lookupCountry(ip) : null) ?? 'XX'

  // Make it readable in Server Components for *this* request...
  const headers = new Headers(request.headers)
  headers.set('x-visitor-country', country)
  const response = NextResponse.next({ request: { headers } })

  // ...and remember it so the next requests skip the lookup.
  // Unknown results are kept briefly, so a failed lookup is retried soon.
  if (!known) {
    const maxAge = country === 'XX' ? 60 * 10 : 60 * 60 * 24
    response.cookies.set(COOKIE, country, { maxAge, path: '/', sameSite: 'lax' })
  }
  return response
}

Then any Server Component reads it:

tsx
import { headers } from 'next/headers'

export default async function PriceNote() {
  const country = (await headers()).get('x-visitor-country') ?? 'XX'
  return country === 'XX' ? null : <p>Showing prices for {country}</p>
}

A few points about this design:

  • NextResponse.next({ request: { headers } }) forwards the header to your app. Setting it on the response instead would send it to the browser. The Proxy docs call out this difference.
  • Missing values are "No Data", not null. That applies to every field in the response, so check before use.
  • The cookie is user-editable. That's fine for "suggest a currency" and wrong for anything with legal or financial weight. For those, look the IP up server-side at the moment of the decision.
  • Keep the timeout short. Proxy runs before rendering, so a slow lookup delays the page. 1,5 seconds and "unknown" on failure is a reasonable default.
  • The key stays on the server. process.env.IPAPI_KEY without a NEXT_PUBLIC_ prefix is never sent to the browser.

The Next.js integration guide covers the full setup with our API, including error codes and the other fields (city, timezone, asn). This article focuses on getting the IP right.

What about client components and React?

The browser doesn't know its own public IP, so a client component can't read it without a network request. Your options:

  • Pass it down from a Server Component as a prop (<CountryPicker initial={country} />). That's the simplest and needs no extra request.
  • Read the cookie set by proxy.ts (document.cookie) if it's not httpOnly.
  • Call your own Route Handler (/api/whoami) that does the lookup server-side.

Avoid calling a geolocation API straight from the browser with a secret key: anyone can copy the key from the network tab. If you must, use a key restricted to your website in the dashboard, and keep in mind what our docs say about it: website checks stop casual reuse, not a determined attacker.

Behind Cloudflare

If your Next.js server sits behind Cloudflare, restore the visitor's address in the reverse proxy from CF-Connecting-IP, limited to Cloudflare's ranges, and keep using x-real-ip in the app. If Next.js faces Cloudflare directly, read cf-connecting-ip instead, but only if the origin's firewall accepts nothing except Cloudflare. The Cloudflare guide has both setups.

Other hosts and containers

The rule doesn't depend on the host: find the one header that your platform's edge sets or overwrites for every request, and read only that one. Platform docs usually say this explicitly, as Vercel's do for X-Forwarded-For. If the docs only say the platform appends to X-Forwarded-For, you can't trust the first entry. Take the rightmost entry the platform added, as described in X-Forwarded-For spoofing.

For Docker and Kubernetes, the reverse proxy or ingress in front of the Next.js container plays the role of Nginx above. Make it set X-Real-IP from the address it sees. If it sits behind a cloud load balancer, configure it to restore the client address from the balancer first, trusting only the balancer's subnet. Then don't publish the Next.js container's port: only the proxy should reach it. If you can't tell what's in front of you, deploy a temporary Route Handler that returns request.headers as JSON, call it once normally and once with a forged x-real-ip, and see which values change.

Don't cache a per-country page for everyone

The most expensive bug in country-aware pages isn't the IP: it's a shared cache. If a page's HTML depends on the visitor's country and something caches that HTML without knowing it, the first visitor's country is served to everyone after them.

  • In Next.js itself, reading headers() or cookies() makes the route dynamic, so Next.js won't prerender it with one visitor's country baked in. Keep it that way: don't move country-dependent output into a statically generated page or a "use cache" function without passing the country in as an argument.
  • In a CDN or reverse proxy in front of Next.js, make sure dynamic routes aren't cached, or vary the cache on something that carries the country. A per-country cookie or header in the cache key works. "Cache everything" rules ignore both.
  • Prefer small dynamic islands. Render the page statically and fetch the country-specific part (a price, a banner) separately. Only that part depends on the visitor, and the rest of the page stays cacheable.

Privacy: keep the country, not the IP

IP addresses are generally treated as personal data under the GDPR and similar laws. For most of the features in this article you never need to store it. Keep the derived value (the country code in a cookie, a region on an order) and let the address go once the request is done. If you log IP addresses for security, keep them in a separate log with its own retention period rather than in analytics tables. In the proxy example above, the IP is used for one lookup and never stored. Only the two-letter code ends up in the cookie.

Testing locally

In next dev there's no proxy, so x-real-ip is absent and the country falls back to XX. To test a country, send the header yourself. That's safe locally because you are the client:

bash
curl -H "x-real-ip: 8.8.8.8" http://localhost:3000/

Never ship a "dev override" that reads a query parameter or header in production.

FAQ

Is middleware.ts still supported in Next.js 16?

It's deprecated and renamed, not removed. Run the codemod to move to proxy.ts. Since Next.js 16 the Proxy runs on the Node.js runtime by default, so Node APIs are available there.

Why is the country wrong for some visitors?

IP geolocation is reliable at country level for most fixed and data-centre networks, but VPNs, corporate proxies and some mobile carriers place users elsewhere. Treat it as a default the user can change. More in how accurate is IP geolocation.

Should search engine crawlers get a country-specific page?

Googlebot mostly crawls from US addresses. If you redirect by country, it may never see your other versions. Suggest instead of redirecting: see redirect visitors by country without locking out search engines.

Look up an IP address now

Free IP lookup: location, network and bot flags, no account needed.