Skip to content

Language Tags — BCP 47 Language Tags and Locale Codes Explained

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Language Tags. We cover key concepts, practical examples, and best practices to help you master this topic.

BCP 47 language tags uniquely identify languages, regions, scripts, and variants for internationalized applications using standardized codes.

What You'll Learn

By the end of this tutorial, you'll understand the structure of BCP 47 language tags, how to use subtags for language, region, script, and variant, and how to parse and validate locale codes in JavaScript.

Why It Matters

Incorrect language tags break locale detection, date formatting, and translation loading. Using "en" when you need "en-US" vs "en-GB" changes date formats, spelling, and currency symbols. Understanding BCP 47 ensures your application correctly identifies and serves the right locale.

Real-World Use

A streaming platform uses BCP 47 tags to select subtitle tracks. The user requests "es-MX" (Mexican Spanish). The platform falls back to "es" (general Spanish) if Mexican-specific subtitles aren't available, then to "en" if no Spanish exists. Without proper tag Parsing, the fallback chain fails and the user gets no subtitles.

Language Tag Structure

graph LR
    A["en-US"] --> B[Primary language
en = English] A --> C[Region
US = United States] D["zh-Hans-CN"] --> E[Primary language
zh = Chinese] D --> F[Script
Hans = Simplified] D --> G[Region
CN = China] H["sr-Latn-RS"] --> I[Primary language
sr = Serbian] H --> J[Script
Latn = Latin script] H --> K[Region
RS = Serbia] L["es-419"] --> M[Primary language
es = Spanish] L --> N[Region
419 = Latin America] style A fill:#4a90d9,color:#fff style D fill:#27ae60,color:#fff style H fill:#f39c12,color:#fff style L fill:#e74c3c,color:#fff

Parsing Language Tags

// i18n/language-tags.js — BCP 47 tag parsing
class LanguageTag {
    constructor(tag) {
        this.tag = tag;
        this.language = null;
        this.script = null;
        this.region = null;
        this.variant = null;
        this.extensions = [];

        this.parse(tag);
    }

    parse(tag) {
        const parts = tag.split('-');

        // First part: primary language (ISO 639-1 or 639-3)
        if (parts.length > 0) {
            this.language = parts[0].toLowerCase();
        }

        // Second part: could be script (ISO 15924) or region (ISO 3166-1)
        if (parts.length > 1) {
            // Script codes are 4 characters, title case (e.g., "Latn")
            if (parts[1].length === 4) {
                this.script = parts[1].charAt(0).toUpperCase() + parts[1].slice(1).toLowerCase();
            }
            // Region codes are 2 letters (ISO 3166-1) or 3 digits (UN M.49)
            else if (parts[1].length === 2 || /^\d{3}$/.test(parts[1])) {
                this.region = parts[1].toUpperCase();
            }
        }

        // Third part: region (if second was script)
        if (parts.length > 2) {
            const third = parts[2];
            if (third.length === 2 || /^\d{3}$/.test(third)) {
                this.region = third.toUpperCase();
            }
        }

        // Remaining parts: variants or extensions
        for (let i = 3; i < parts.length; i++) {
            if (parts[i].length >= 5 && /^[a-z]/.test(parts[i])) {
                this.variant = parts[i];
            } else if (parts[i].length === 1) {
                this.extensions.push(parts[i]);
            }
        }
    }

    toString() {
        let result = this.language;
        if (this.script) result += `-${this.script}`;
        if (this.region) result += `-${this.region}`;
        if (this.variant) result += `-${this.variant}`;
        return result;
    }

    // Check if this tag matches another, considering fallbacks
    matches(other, options = {}) {
        const otherTag = typeof other === 'string' ? new LanguageTag(other) : other;

        // Exact match
        if (this.tag === otherTag.tag) return true;

        // Language + region match (e.g., en-US matches en-US)
        if (this.language === otherTag.language &&
            this.region && otherTag.region &&
            this.region === otherTag.region) return true;

        // Language match with fallback (e.g., en-US matches en)
        if (options.fallback && this.language === otherTag.language) return true;

        return false;
    }

    // Get the parent tag (for fallback chains)
    parent() {
        if (this.region) {
            return new LanguageTag(this.language + (this.script ? `-${this.script}` : ''));
        }
        if (this.script) {
            return new LanguageTag(this.language);
        }
        return null;
    }

    // Validate the tag structure
    static validate(tag) {
        const pattern = /^([a-z]{2,3})(-[A-Z][a-z]{3})?(-[A-Z]{2}|\-\d{3})?(-[a-z]{5,})?(-[a-z])?$/i;
        return pattern.test(tag);
    }
}

// Usage examples
const tags = [
    new LanguageTag('en-US'),
    new LanguageTag('zh-Hans-CN'),
    new LanguageTag('sr-Latn-RS'),
    new LanguageTag('es-419'),
    new LanguageTag('en'),
];

tags.forEach(t => {
    console.log(`${t.tag}: lang=${t.language}, script=${t.script || 'none'}, region=${t.region || 'none'}`);
});

// Fallback chain example
const userTag = new LanguageTag('es-MX');
console.log('Fallback chain:');
let current = userTag;
while (current) {
    console.log(`  ${current.tag}`);
    current = current.parent();
}

Locale Resolution with Fallbacks

// i18n/locale-resolver.js — Find best locale match
class LocaleResolver {
    constructor(supportedLocales = []) {
        this.locales = supportedLocales.map(l => new LanguageTag(l));
    }

    // Find best match for user locale
    resolve(userLocale) {
        const userTag = new LanguageTag(userLocale);

        // 1. Exact match
        const exact = this.locales.find(l => l.tag === userTag.tag);
        if (exact) return exact.tag;

        // 2. Language + region match
        const regionMatch = this.locales.find(l =>
            l.language === userTag.language &&
            l.region === userTag.region
        );
        if (regionMatch) return regionMatch.tag;

        // 3. Language match (any region)
        const langMatch = this.locales.find(l =>
            l.language === userTag.language
        );
        if (langMatch) return langMatch.tag;

        // 4. Script match (for Chinese, Arabic, etc.)
        if (userTag.script) {
            const scriptMatch = this.locales.find(l =>
                l.script === userTag.script
            );
            if (scriptMatch) return scriptMatch.tag;
        }

        // 5. Default to first supported
        return this.locales[0]?.tag || 'en-US';
    }

    // Build Accept-Language style ranking
    prioritize(locales) {
        return locales
            .map(locale => ({ tag: locale, tagObj: new LanguageTag(locale) }))
            .map(item => ({
                ...item,
                priority: this.calculatePriority(item.tagObj)
            }))
            .sort((a, b) => b.priority - a.priority)
            .map(item => item.tag);
    }

    calculatePriority(tag) {
        let score = 0;
        if (this.locales.find(l => l.tag === tag.tag)) score += 100;
        if (this.locales.find(l => l.language === tag.language)) score += 50;
        if (this.locales.find(l => l.script === tag.script)) score += 25;
        return score;
    }
}

const resolver = new LocaleResolver(['en-US', 'en-GB', 'es-ES', 'fr-FR', 'zh-Hans-CN']);

console.log('User es-MX resolves to:', resolver.resolve('es-MX'));
console.log('User en resolves to:', resolver.resolve('en'));
console.log('User zh-Hant-TW resolves to:', resolver.resolve('zh-Hant-TW'));

Common Mistakes

  1. Using only language codes without regions. "en" is ambiguous — it could be US English (en-US), British English (en-GB), or Australian English (en-AU). Use region subtags when format differences matter.
  2. Case sensitivity in tags. BCP 47 specifies lowercase for language, title case for script, uppercase for region. "EN-US" and "en-us" both work in most browsers, but stick to the standard for consistency.
  3. Assuming all speakers of a language use the same region. Spanish has es-ES (European), es-MX (Mexican), es-AR (Argentinian). The language is the same, but date formats and vocabulary differ.
  4. Ignoring script subtags for Chinese. zh-Hans (Simplified, used in mainland China) and zh-Hant (Traditional, used in Taiwan/Hong Kong) are different scripts. Using zh-CN assumes Simplified, but zh-SG might be Simplified or Traditional.
  5. Not handling deprecated tags. Some language tags are deprecated (e.g., "iw" for Hebrew, "in" for Indonesian). Use the current codes ("he" and "id") and map old codes to new ones.

Practice Questions

  1. What is the structure of a BCP 47 language tag?
  2. How does the fallback chain work when a specific locale isn't available?
  3. What is the difference between zh-Hans-CN and zh-Hant-TW?
  4. Why should you use region-specific tags like en-GB instead of just en?
  5. How do you validate a language tag in JavaScript?

Challenge: Build a locale resolver that accepts a user's preferred locales (from Accept-Language header or navigator.languages) and a list of supported locales. Implement a fallback chain that tries exact match, language+region, language-only, and finally a default. Display the resolved locale and the fallback path.

FAQ

What is a BCP 47 language tag?

BCP 47 (Best Current Practice 47) is the standard for identifying languages. Tags use subtags separated by hyphens: language (en), script (Latn), region (US), variant (valencia). Example: en-US, zh-Hans-CN.

How do I get the user's language in the browser?

Use navigator.language for the primary language, navigator.languages for the prioritized list. These return BCP 47 tags like 'en-US', 'es-ES'. Server-side, use the Accept-Language HTTP header.

What's the difference between ISO 639-1 and ISO 639-3?

ISO 639-1 uses 2-letter codes (en, es, fr) and covers major languages. ISO 639-3 uses 3-letter codes and covers all known languages (~7,800). Most web i18n uses 639-1 codes.

Should I use 'en' or 'en-US' as my default locale?

Use en-US as default for general audiences. 'en' is technically valid but ambiguous. en-US gives you explicit US date/number/currency formats, which is the most common expectation for English web content.

How do I handle Chinese locale selection?

Chinese has two major script variants: zh-Hans (Simplified, mainland China) and zh-Hant (Traditional, Taiwan/Hong Kong). Always include the script subtag. zh-CN implicitly is Simplified, zh-TW is Traditional, but explicit script tags are clearer.

Mini Project

Build a language tag explorer: create a web page that accepts a BCP 47 language tag as input, parses and displays its components (language, script, region, variant), shows the fallback chain, lists all supported locales that match, and validates the tag structure. Include presets for common tags (en-US, zh-Hant-TW, sr-Latn-RS, es-419).

What's Next

You've mastered language tags. Next, learn how to implement Locale Detection to automatically determine the user's preferred locale from browser and server signals.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro