Site icon GetPageSpeed

NGINX Accept-Language Redirects: No Lua Required

NGINX Accept-Language Redirects: No Lua Required

Your site speaks three languages, but your NGINX config does not. Stock NGINX has no built-in way to parse the Accept-Language header, so the practical fix is the NGINX Accept-Language module: its set_from_accept_language directive, shipped as the prebuilt nginx-module-accept-language package, picks the best supported language for every visitor in one line of configuration, without Lua, njs, or regex hacks.

If you have ever searched for “nginx accept-language redirect”, you have seen the workarounds: a map block with a pile of regexes, an njs script, or a whole OpenResty/Lua layer bolted on just to read one request header. All of that exists because the header is genuinely annoying to parse by hand. A real-world value looks like this:

Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5

Regional subtags, quality values, spaces, wildcards. A regex that handles all of it correctly stops being a one-liner very quickly. Meanwhile, all you actually want is: “this visitor prefers French, my site has /fr/, send them there.”

This guide shows how to do exactly that with the NGINX accept-language module: install the package, add one directive, and redirect first-time visitors to /en/, /fr/ or /de/ based on their browser preference. Every configuration below was tested end to end on a clean Rocky Linux 10 server with NGINX 1.30.

How the NGINX Accept-Language Module Works

The module provides a single directive. You give it a variable name and the list of languages your site actually supports:

set_from_accept_language $lang en fr de;

On each request, the module walks the visitor’s Accept-Language header from left to right and compares every language tag against your supported list. The comparison is a case-insensitive prefix match, therefore a browser sending fr-CA or fr-CH matches your fr just fine. The first header tag that matches any supported language wins, and $lang is set to that supported language.

Two behaviors are worth knowing precisely, because they differ from what some tutorials claim:

Because the output can only ever be one of the values you listed, $lang is safe to use in return, error_page, or logging. Untrusted header input never reaches your URLs.

Installing nginx-module-accept-language

The module is packaged and continuously rebuilt against current NGINX by GetPageSpeed for all major Linux distributions.

RHEL, CentOS, AlmaLinux, Rocky Linux, Amazon Linux

sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-accept-language

Installing the module also brings in the latest stable NGINX from the same repository, so a stock 1.28.x setup is upgraded to the current 1.30.x in the same transaction. Then load the module at the very top of /etc/nginx/nginx.conf, above any other directive:

load_module modules/ngx_http_accept_language_module.so;

Debian and Ubuntu

First, set up the GetPageSpeed APT repository, then install:

sudo apt-get update
sudo apt-get install nginx-module-accept-language

On Debian/Ubuntu, the package handles module loading automatically. No load_module directive is needed.

Package details for each platform live on the module pages: RPM-based systems and Debian/Ubuntu.

Directive Reference

Directive Syntax Context
set_from_accept_language set_from_accept_language $variable lang1 [lang2 ...]; http, server, location

The variable name is yours to choose. One important rule: each variable name may be declared only once in the entire configuration. Declare it at the http level when several server blocks need it (more on that in Troubleshooting).

Configuration 1: Redirect Visitors by Browser Language

The classic i18n setup: language trees under /en/, /fr/ and /de/, and the site root decides where a new visitor lands.

server {
    listen 80;
    server_name example.com;
    root /var/www/i18n;

    # Pick the best supported language; first one (en) is the fallback
    set_from_accept_language $lang en fr de;

    location = / {
        add_header Vary Accept-Language always;
        return 302 /$lang/;
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

Only the exact site root redirects. Deep links like /fr/pricing are never rewritten, so shared URLs keep working for everyone regardless of their browser locale.

Verified behavior on Rocky Linux 10 with NGINX 1.30.5:

curl -s -o /dev/null -w "%{redirect_url}\n" -H "Accept-Language: fr-FR,fr;q=0.9,en;q=0.8" http://localhost/
# http://localhost/fr/

curl -s -o /dev/null -w "%{redirect_url}\n" -H "Accept-Language: de-CH" http://localhost/
# http://localhost/de/  (regional subtag matched by prefix)

curl -s -o /dev/null -w "%{redirect_url}\n" -H "Accept-Language: es-ES,es;q=0.9" http://localhost/
# http://localhost/en/  (unsupported language falls back to the first listed)

curl -s -o /dev/null -w "%{redirect_url}\n" http://localhost/
# http://localhost/en/  (no header at all falls back too)

curl -s -o /dev/null -w "%{redirect_url}\n" -H "Accept-Language: pt;q=0.9, de;q=0.8" http://localhost/
# http://localhost/de/  (pt is skipped, the next tag wins)

Configuration 2: Let an Explicit Choice Beat Auto-Detection

Auto-detection is a first-visit convenience, not a cage. When a visitor clicks your language switcher, store the choice in a cookie and let it override the header. A stock map combines cleanly with the module’s variable:

# An explicit choice stored in a cookie wins over header detection
map $cookie_site_lang $lang_final {
    default $lang_detected;
    en      en;
    fr      fr;
    de      de;
}

server {
    listen 80;
    server_name example.com;
    root /var/www/i18n;

    set_from_accept_language $lang_detected en fr de;

    location = / {
        add_header Vary Accept-Language always;
        return 302 /$lang_final/;
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

The map whitelists cookie values, so a tampered cookie falls through to header detection instead of reaching the redirect. Verified:

# Cookie beats the header
curl -s -o /dev/null -w "%{redirect_url}\n" \
  -H "Accept-Language: fr-FR,fr;q=0.9" -H "Cookie: site_lang=de" http://localhost/
# http://localhost/de/

# A bogus cookie value is ignored
curl -s -o /dev/null -w "%{redirect_url}\n" \
  -H "Accept-Language: fr-FR,fr;q=0.9" -H "Cookie: site_lang=xx" http://localhost/
# http://localhost/fr/

Configuration 3: Log the Detected Language

Before rolling out language trees, it pays to know your actual audience split. Declare the variable once at the http level and add it to a log format:

# In the http block (e.g. /etc/nginx/conf.d/00-lang.conf)
set_from_accept_language $lang en fr de;

log_format i18n '$remote_addr "$request" $status '
                '"$http_accept_language" lang=$lang';

Then use access_log /var/log/nginx/i18n.log i18n; in any server. Sample of the verified output:

127.0.0.1 "GET / HTTP/1.1" 302 "de-DE,de;q=0.9,en;q=0.5" lang=de
127.0.0.1 "GET / HTTP/1.1" 302 "pt-BR" lang=en

A week of this log tells you whether that Portuguese tree is worth building.

What About map, njs, or Lua?

NGINX’s built-in map directive can approximate language detection with regexes like ~*^fr fr;. That works while the very first header tag is one you support. It falls apart on real headers: map tests the whole header string once, so for Accept-Language: pt;q=0.9, de;q=0.8 a regex map anchored at the start sees pt, matches nothing, and serves the default even though the visitor also accepts German. The module walks all tags and gets this case right.

We use the map approach ourselves where it fits; our guide to localized NGINX error pages shows both methods side by side for the error-page use case. This article’s site-wide redirects are exactly where the map shortcut breaks down and the module earns its place.

The njs and Lua routes solve the parsing correctly, but at the cost of an entire scripting runtime, extra packages, and code you now maintain and security-patch yourself. For one request header, that is a lot of machinery. A compiled 68 KB module with zero configuration surface is the boring, right-sized tool.

Performance

The NGINX accept-language module registers a variable handler, which means it runs only when $lang is actually evaluated, and it does a single linear pass over the header with plain case-insensitive comparisons. No regex engine, no interpreter, no allocations in the match path. On requests that never touch $lang, the cost is exactly zero. This is as close to free as request-time logic gets in NGINX.

SEO Best Practices for Language Redirects

Language redirects sit on your most-visited URL, so a few rules keep search engines happy:

  1. Use 302, not 301. The redirect target depends on who is asking. A 301 is cacheable and may be reused for the next visitor with different preferences, and Google treats a permanent redirect on the root as a signal to index the target instead.
  2. Send Vary: Accept-Language on the redirect response, as the configs above do, so shared caches and CDNs store one redirect per language rather than one for everyone.
  3. Keep hreflang annotations on your language trees, including x-default pointing at the root redirector. The redirect handles humans; hreflang handles crawlers.
  4. Never redirect deep URLs. Match location = / only. Googlebot crawls mostly without Accept-Language and must be able to reach every language tree directly.
  5. Honor explicit choices with the cookie override from Configuration 2. Auto-detection guessing wrong once is fine; guessing wrong on every visit is how you lose readers.

Troubleshooting

unknown directive "set_from_accept_language" on nginx -t: the module is not loaded. On RPM-based systems, confirm the load_module line sits at the very top of nginx.conf, above the events and http blocks:

nginx: [emerg] unknown directive "set_from_accept_language" in /etc/nginx/conf.d/00-lang.conf:1

variable already defined: "lang": the same variable name is declared in more than one place, for example in two server blocks:

nginx: [emerg] variable already defined: "lang" in /etc/nginx/conf.d/site2.conf:7

Each set_from_accept_language variable is registered once for the whole configuration. Declare it a single time at the http level and every server block can use it.

Regional variants need ordering care. The prefix match means a supported zh also catches zh-TW visitors. If you maintain separate Simplified and Traditional Chinese trees, list the specific variant before the generic one, for example set_from_accept_language $lang en zh-TW zh;, and remember that the fallback is always the first entry.

Behind a CDN? Confirm your edge honors Vary: Accept-Language on the root URL, or exclude / from edge caching. Otherwise the first visitor’s language gets cached and served to everyone.

Get the Module

nginx-module-accept-language is one of 140+ NGINX modules prebuilt, security-patched, and rebuilt against every NGINX release in the GetPageSpeed Premium Repository. One subscription covers every module on all your servers, with packages for every major RHEL-compatible, Debian, and Ubuntu release. Subscribe and install in under a minute.

Conclusion

A working nginx-module-accept-language setup only stays correct until the next config edit. Language redirects sit at your busiest entry point, and a careless change there can quietly break caching or misroute every international visitor. GetPageSpeed Amplify runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics. Drop-in compatible with the deprecated nginx-amplify-agent (EOL January 2026).

Browser-language redirects in NGINX do not require Lua, njs, or a fragile regex map. The nginx accept-language module reduces the whole problem to one tested directive: install the package, declare your supported languages, and redirect the root. The Accept-Language header does the rest, with an explicit cookie override for visitors who know better than their browser settings.

Module source and issue tracker: dvershinin/nginx_accept_language_module on GitHub.

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Exit mobile version