How HTTP Works

Request and response header

< Content-Language: en-US

Content-Language Header

Learn how the Content-Language header specifies the natural language(s) of response content. Understand language tags and internationalization best practices.

Reviewed 6 min readbeginner2 sourcesTry itMarkdown
Direction
Request and response
Category
Representation
Spec
RFC 9110 §8.5
JS can set it
Yes
CORS-safelisted
Yes
On this page

TL;DR: Content-Language is a response header that says who the content is for, such as Content-Language: en-US. It names the intended audience, not every language that appears in the text. To mark the language of the text itself, use the HTML lang attribute.

What is Content-Language?

Picture a German course written for English speakers. It’s full of German examples, but its audience is English readers, so Content-Language: en is right. Audience and text language are different ideas. RFC 9110 §8.5 defines the audience meaning.

How Content-Language Works

Response headers with the body omitted (headers on this page are trimmed examples):

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Language: en

When the field is missing, the content is meant for every language audience. A missing header doesn’t mean “English”, and the header has nothing to do with character encoding either way.

Syntax

Content-Language: en-US
Content-Language: en, fr

Use plain language tags. q weights and * belong in requests, not here. List several tags when the content serves several audiences. MDN gives the syntax and audience examples.

Common Examples

Content-Language: en, fr fits content written for both English and French readers. An English article that quotes one French sentence is still for English readers, so en is enough.

Take a bilingual instruction sheet with the complete instructions in both English and French. Either group can use it, so en, fr describes its audience. A vocabulary worksheet that teaches French through English explanations is for English speakers, so it declares en.

In that worksheet, mark the French text where it appears:

<p lang="en">The French word for bread is <span lang="fr">pain</span>.</p>

The header can’t do this job for you. Screen readers and speech software need to know exactly where pronunciation changes, not just who the server thinks will read the page.

Real-World Scenarios

Say /welcome negotiates language and picks French. It returns Content-Language: fr with Vary: Accept-Language. A fixed /fr/welcome route doesn’t need that Vary, because Accept-Language doesn’t change its response.

APIs can localize too. A French response keeps the key status and translates the human-readable value. Content-Language: fr covers the messages; protocol tokens and JSON property names stay as they are.

For a response negotiated between two translations:

Content-Type: application/json
Content-Language: fr
Vary: Accept-Language

If the resource always contains both translations, its audience might be en, fr, and it has no reason to vary by the request’s language preference. Set the header from what you actually send, rather than copying the request field.

Server Implementation

This nginx route goes inside a server block:

location = /welcome.fr.txt {
    default_type text/plain;
    charset utf-8;
    add_header Content-Language fr;
    return 200 "Bienvenue\n";
}

add_header sets the audience and the charset module sets UTF-8. This route always serves French; nothing is negotiated.

An Express route can pick from the translations the app has:

const greetings = { en: 'Welcome', fr: 'Bienvenue' }
app.get('/welcome', (req, res) => {
  res.vary('Accept-Language')
  const language = req.acceptsLanguages(['en', 'fr']) || 'en'
  res.set('Content-Language', language)
  res.json({ message: greetings[language] })
})

When nothing matches, this route falls back to English. Note that the header carries the language the route chose, not the client’s whole preference list. Express’s request API documents the matching helper.

A fixed French Django view needs no negotiation at all:

from django.http import JsonResponse

def welcome_fr(request):
    response = JsonResponse({'message': 'Bienvenue'})
    response['Content-Language'] = 'fr'
    return response

Set the header on the response object the view returns. Django response objects support dictionary-style header assignment.

For Apache serving a directory of French documents, with mod_headers loaded:

<Directory "/srv/www/fr">
    Header set Content-Language "fr"
</Directory>

Header set replaces any existing value. Keep it scoped to the French documents. Set it site-wide and every other translation gets labeled French too.

Getting Content-Language right

In HTML, mark the document language and any language changes in the markup:

<html lang="en">
  <head><title>French lesson</title></head>
  <body><p>The French greeting is <span lang="fr">bonjour</span>.</p></body>
</html>

The HTML Standard recommends a root language declaration to help speech synthesis and translation tools. The audience header doesn’t have to match every lang attribute on the page.

Language and writing direction are separate settings. For an Arabic document, declare both the language and the layout direction:

<html lang="ar" dir="rtl">
  <head><meta charset="utf-8"><title>مرحبا</title></head>
  <body><p>مرحبا</p></body>
</html>

Content-Language: ar won’t flip the layout or pick UTF-8 for you. The HTML dir attribute controls direction and the charset declaration controls decoding. A language tag does neither.

Language Tags

Tags follow BCP 47, which allows language, script, region and other subtags. You’re not limited to two ISO codes.

Header valueIntended audience
en-USUS English readers
pt-BRBrazilian Portuguese readers
zh-HantReaders of Chinese in Traditional script
en, frEnglish and French readers

Two common mistakes: en_US uses the locale-style underscore instead of a hyphen, and en;q=0.8 is request-negotiation syntax. This response field takes plain tags, never a weighted preference list.

Tags are case-insensitive. By convention regions are uppercase and scripts are title-case, but EN-us and en-US mean the same language. Treat them as one translation, not two.

Content-Language vs Accept-Language

Accept-Language is what the client would like. Content-Language describes what the server actually sent. Sending one doesn’t mean the server looked at the other, so add Vary: Accept-Language only on routes that really negotiate.

A client can send Accept-Language: fr to an English-only resource and get Content-Language: en back. That’s not a contradiction; the server is accurately describing what it sent. Look at the route’s selection logic before calling mismatched values a bug. On negotiated endpoints, also check that a cached English response isn’t being served to French requests.

SEO Considerations

This header describes the current response. It doesn’t tell search engines where the other translations live, so it can’t stand in for links between translated pages.

For an English/French pair, put these annotations in the HTML head of both pages, using their real published URLs:

<link rel="alternate" hreflang="en" href="https://example.com/en/welcome">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/welcome">

Google’s localized-page documentation requires each set to include the page itself plus its alternatives, linked in both directions. Content-Language: fr on its own can’t express that pairing. The annotations point to alternatives. Google still decides which URL to show each searcher, and nothing gets translated for you.

Testing Content-Language

curl -sS -D - -o /dev/null https://example.com/welcome.fr.txt

For a negotiated route, repeat with different Accept-Language values and check both Content-Language and Vary:

curl -sS -D - -o /dev/null -H 'Accept-Language: en' https://example.com/welcome
curl -sS -D - -o /dev/null -H 'Accept-Language: fr' https://example.com/welcome

When chasing a language mismatch, compare the body as well as the header. A response labeled fr that still contains the English message means the app picked the wrong translation. If the origin gets it right but the public URL doesn’t, look at Vary and your cache configuration. Check the HTML lang declaration on its own, too: a correct header won’t fix missing or wrong markup.

Same-origin Fetch can read the field:

const response = await fetch('/welcome.fr.txt')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
console.log(response.headers.get('Content-Language'))
console.log(await response.text())

A missing field comes back as null. Keep it that way in any diagnostic tool you write. Turning null into a guessed en swaps HTTP’s all-audiences default for an assumption of your own.

Content-Language is a CORS-safelisted response field, so a permitted cross-origin Fetch can read it without listing it in Access-Control-Expose-Headers. The server still has to allow the cross-origin request in the first place.

Frequently asked questions

What is Content-Language?

Content-Language identifies the languages of the intended audience, such as en-US. Use HTML lang attributes to identify the language of document text.

Is Content-Language the same as Accept-Language?

Accept-Language expresses request preferences. Content-Language describes the intended audience of a representation; it is not a list of every language in the text.

Can Content-Language have multiple values?

Yes, for multilingual content: Content-Language: en, fr. This indicates the content is intended for both English and French speakers.

Does Content-Language affect character encoding?

No, Content-Language is about human language, not encoding. Use Content-Type charset for encoding. A page can be in Japanese (Content-Language: ja) with UTF-8 encoding.

Sources

  1. MDN Web Docs: Content-Languagedeveloper.mozilla.org
  2. RFC 9110: Content-Languagerfc-editor.org

Keep going

Browse /search