Redirect Visitors by Browser Language with Cloudflare Pages Functions

How I send visitors of a static multilingual site straight to their language using a Cloudflare Pages Functions middleware that reads Accept-Language and a language cookie, then 302-redirects to the translated page while leaving search bots alone.

Why a static multilingual site needed language redirects

This blog keeps the original Korean posts at the base URL (/<post>) and the English and Japanese translations at /en/<post> and /ja/<post>. It's a static site: every page is generated as an HTML file at build time and uploaded to Cloudflare Pages.

The problem was that most incoming links, from search results or shares, point to the Korean URL. Visitors from the US or Japan land on a Korean page first, and if they don't realize a translation exists, they just leave.

Normally the server would look at the browser language and serve the right page. But a static site only hands out prebuilt files, so there's no server to make a per-request decision.

So I used Cloudflare Pages Functions. Pages Functions let you intercept a request and run a bit of code before the static file is served. A file at functions/_middleware.ts becomes middleware (code that sits between the request and the response) that runs in front of every request on the site1.

Deciding which language to send visitors to

I set some rules first. Moving visitors around without good reason can be annoying, so the principle is: redirect only when we're confident, and only when a translation exists.

  1. If the visitor picked a language from the site's language menu, follow that choice. If they picked Korean, don't redirect.
  2. Otherwise, look at the Accept-Language header the browser sends. It lists the preferred languages from the browser settings, with priorities (q values), like en-US,en;q=0.9.
  3. If none of the preferred languages is supported by the site (Korean, English, Japanese), use English.
  4. Redirect only if the translated page actually exists.

And these requests are never redirected:

  • Search engine bots: redirecting bots could keep the Korean originals from being indexed.
  • Requests without Accept-Language: we can't tell the language, so the original page is shown as-is.
  • URLs already under /en or /ja: the visitor is already reading a translation.

The language menu stores the chosen language in a lang cookie for one year. Here's the code from src/components/language-select.tsx:

document.cookie = `${LOCALE_COOKIE}=${target}; path=/; max-age=31536000; samesite=lax`;

The full functions/_middleware.ts

This is the functions/_middleware.ts I actually use. Values like LOCALES (ko, en, ja) and localePath (a helper that builds the URL for a given language) are shared with the site code (src/lib/i18n.ts).

// Cloudflare Pages Function: 한국어 주소로 처음 들어온 방문자를 브라우저 언어(Accept-Language)에 맞는 /en·/ja 페이지로 보낸다.
// - 언어 select로 고른 언어(lang 쿠키)가 있으면 브라우저 언어보다 그 선택을 따른다(한국어를 골랐으면 이동하지 않는다).
// - 검색엔진 봇과 Accept-Language가 없는 요청은 이동시키지 않는다(한국어 원문이 색인되도록).
// - 이동할 번역 페이지가 없으면 한국어 페이지를 그대로 보여준다. 한국어 페이지도 없으면(404) 그 언어의 404 페이지를 보여준다.
// 정적 자산은 public/_routes.json에서 제외해 이 함수가 실행되지 않는다.
import {DEFAULT_LOCALE, LOCALE_COOKIE, LOCALES, Locale, isLocale, localePath} from "../src/lib/i18n";

type PagesContext = {
  request: Request;
  next: () => Promise<Response>;
  env: { ASSETS: { fetch: (input: Request | string | URL) => Promise<Response> } };
};

const BOT_UA = /bot|crawl|spider|slurp|yeti|daum|bingpreview|facebookexternalhit|embedly|preview/i;
// 지원하지 않는 언어만 받는 방문자는 영어로 보낸다.
const FALLBACK_LOCALE: Locale = 'en';

function cookieLocale(request: Request): Locale | null {
  const cookie = request.headers.get('Cookie') ?? '';
  for (const c of cookie.split(';')) {
    const [name, value] = c.trim().split('=');
    if (name === LOCALE_COOKIE && isLocale(value ?? '')) return value as Locale;
  }
  return null;
}

function preferredLocale(acceptLanguage: string | null): Locale | null {
  if (!acceptLanguage) return null;
  const langs = acceptLanguage.split(',')
    .map((part, index) => {
      const [tag, ...params] = part.trim().split(';');
      const q = params.map(p => p.trim()).find(p => p.startsWith('q='));
      return {lang: tag.trim().toLowerCase().split('-')[0], q: q ? parseFloat(q.slice(2)) : 1, index};
    })
    .filter(l => l.lang && l.lang !== '*' && l.q > 0)
    .sort((a, b) => b.q - a.q || a.index - b.index);
  if (langs.length === 0) return null;
  return langs.map(l => l.lang).find(isLocale) ?? FALLBACK_LOCALE;
}

export const onRequest = async ({request, next, env}: PagesContext): Promise<Response> => {
  if (request.method !== 'GET') return next();

  const url = new URL(request.url);
  const path = url.pathname;
  const otherLocales = LOCALES.filter(l => l !== DEFAULT_LOCALE);
  if (otherLocales.some(l => path === `/${l}` || path.startsWith(`/${l}/`))) return next();
  if (/\.[a-z0-9]+$/i.test(path)) return next();
  if (BOT_UA.test(request.headers.get('User-Agent') ?? '')) return next();

  const target = cookieLocale(request) ?? preferredLocale(request.headers.get('Accept-Language'));
  if (!target || target === DEFAULT_LOCALE) return next();

  const targetPath = localePath(target, path);
  const exists = await env.ASSETS.fetch(new URL(targetPath, url.origin));
  if (!exists.ok) {
    const response = await next();
    if (response.status !== 404) return response;
    const notFound = await env.ASSETS.fetch(new URL(localePath(target, '/404'), url.origin));
    return notFound.ok ? new Response(notFound.body, {status: 404, headers: notFound.headers}) : response;
  }

  return new Response(null, {
    status: 302,
    headers: {
      'Location': `${targetPath}${url.search}`,
      'Vary': 'Accept-Language, Cookie',
      'Cache-Control': 'private, no-store',
    },
  });
};

Let's go through the highlighted parts.

Reading Accept-Language in priority order

preferredLocale splits the Accept-Language value on commas, reads each language's q value, and sorts from highest to lowest. Ties keep their original order. Region-qualified values like en-US are reduced to the first part (en).

It then picks the first language the site supports, or English (FALLBACK_LOCALE) if there's none. For example, a visitor who only speaks French goes to the English page.

Filtering out requests that shouldn't be redirected

At the top of onRequest, these requests are passed straight to next(), which means "serve the static file as usual":

  • Non-GET requests
  • URLs already under /en or /ja
  • File URLs with an extension, like .png or .xml
  • Requests whose User-Agent (the name of the requesting program) contains words like bot, crawl, or spider

Checking that the translation exists, then redirecting with 302

Once the target language is known, env.ASSETS.fetch requests the static file at the translated URL directly. If it comes back OK, the translation exists, so the middleware responds with a 302 to that URL.

If there's no translation, the Korean page is shown as-is. If even the Korean page doesn't exist (an unknown URL), the visitor gets the 404 page in their own language.

Why 302 and these cache headers

Three details matter in the redirect response:

  • 302 (temporary redirect): the same URL leads to different places for different visitors. A permanent redirect like 301 or 308 would make browsers and search engines remember "this URL always goes there."
  • Vary: Accept-Language, Cookie: tells caches that the response depends on these two headers, so a cache never hands an English visitor's response to a Korean visitor.
  • Cache-Control: private, no-store: keeps the redirect itself from being cached at all, so a later change in the language menu takes effect immediately.

Limiting where the function runs with _routes.json

By default, the middleware runs on every request. But images, fonts, scripts, and the /en and /ja pages don't need a language decision. Running the function on them only slows responses down and adds invocations.

So public/_routes.json excludes those paths. Paths in exclude don't invoke the function and aren't billed as function invocations2.

{
  "version": 1,
  "include": ["/*"],
  "exclude": [
    "/assets/*",
    "/images/*",
    "/fonts/*",
    "/en",
    "/en/*",
    "/ja",
    "/ja/*",
    "/*.xml",
    "/*.txt",
    "/*.png",
    "/*.ico",
    "/*.svg",
    "/*.webmanifest",
    "/*.json",
    "/*.data"
  ]
}

Checking the redirects with curl

After deploying, I sent requests with different conditions to the live site using curl. Don't use curl -I for this. -I sends a HEAD request, and since the middleware passes through anything that isn't GET, you'll always get 200. That fooled me at first into thinking the middleware wasn't working. Instead, use -s -o /dev/null -D - to send a GET and print only the response headers.

An English browser is sent to the English translation:

$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US,en;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
vary: Accept-Language, Cookie
location: /en/win-acme-validation-port
cache-control: private, no-store

A Japanese browser goes to Japanese, and an unsupported French browser goes to English:

$ curl -s -o /dev/null -D - -H 'Accept-Language: ja-JP,ja;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
location: /ja/win-acme-validation-port

$ curl -s -o /dev/null -D - -H 'Accept-Language: fr-FR,fr;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
location: /en/win-acme-validation-port

In these cases there's no redirect, and the Korean page (200) is shown:

# Browser with Korean as first choice
$ curl -s -o /dev/null -D - -H 'Accept-Language: ko-KR,ko;q=0.9,en;q=0.8' https://sncstudio.net/win-acme-validation-port
HTTP/2 200

# Visitor who picked Korean in the language menu (lang cookie)
$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US' -H 'Cookie: lang=ko' https://sncstudio.net/win-acme-validation-port
HTTP/2 200

# Search engine bot
$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US' -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' https://sncstudio.net/win-acme-validation-port
HTTP/2 200

An English visitor hitting a nonexistent URL gets the English 404 page (<html lang="en">).

Things to keep in mind

  • Use it together with hreflang. Bots aren't redirected, so search engines learn about translations only through per-language links like <link rel="alternate" hreflang="en" ...> on each page. This blog includes ko, en, ja, and x-default on every page.
  • Always provide a language menu. Automatic detection can be wrong. Some people with a foreign-language browser want to read Korean, so an explicit choice (the cookie) must always win.
  • Keep your URL format consistent. Redirect targets use URLs without a trailing slash. I wrote up how to keep that format in How to Stop Cloudflare Pages Trailing Slash Redirects.
  • To test locally, run bunx wrangler pages dev out, which serves functions/ as well.

References

Footnotes

  1. https://developers.cloudflare.com/pages/functions/middleware/ ↩

  2. https://developers.cloudflare.com/pages/functions/routing/ ↩

2026 © snc studio·Privacy Policy