@portable/idna
Converts internationalized domain names between Unicode and the ASCII form that DNS and URL use, by UTS #46 with Unicode 18.0.0 tables. Runs in browsers, Cloudflare Workers, Deno, Bun and Node 24+.
Replaces punycode, the deprecated node:punycode, and tr46.
npm install @portable/idnaimport * as Idna from '@portable/idna';
const { hostname } = new URL('https://bücher.example/');
hostname; // 'xn--bcher-kva.example'
Idna.toUnicode(hostname); // 'bücher.example'
Idna.toAscii('Bücher.example'); // 'xn--bcher-kva.example'URL converts hostnames to ASCII, but the platform has no function that converts them back to Unicode. toUnicode is that function. toAscii gives the same result as URL without parsing a URL, and in every runtime.
API
toUnicode(domain, options?)
Decodes each xn-- label and checks the result against UTS #46 with the URL Standard's settings: the bidi rule (CheckBidi), the joiner rules (CheckJoiners) and nontransitional processing. Unicode input is mapped and normalized too, so toUnicode('BÜCHER.example') returns 'bücher.example'.
It throws Idna.InvalidInputError when the domain breaks a rule, such as an xn-- label that isn't valid Punycode or that decodes to a character IDNA doesn't allow. UTS #46 lets the caller decide what to show instead. Browsers show the ASCII form:
function displayHost(hostname: string): string {
try {
return Idna.toUnicode(hostname);
} catch {
return hostname;
}
}toAscii(domain, options?)
Follows the URL Standard's domain parser: ASCII input is lowercased, and other input goes through UTS #46 ToASCII, which maps, normalizes, validates and encodes each non-ASCII label as xn-- Punycode. It throws Idna.InvalidInputError when the domain breaks a rule, when the result is empty, or when the result contains a character that URL forbids in a host, such as a space, %, / or @.
The result equals new URL('http://' + domain).hostname with three exceptions. URL parses a host that ends in a number as an IPv4 address (0x7f.1 becomes 127.0.0.1), and decodes percent-encoding. Neither is IDNA, and toAscii does neither. The third is that runtimes don't agree with each other or with the standard, as the next section shows.
Options
| Option | Default | Description |
|---|---|---|
strict | false | Apply the STD 3 hostname rules: only a-z, 0-9 and - in a label, and no - at either end of a label or in its third and fourth positions. In toAscii it also checks DNS lengths: every label is 1 to 63 characters and the name at most 253, so a trailing dot throws. This is the URL Standard's beStrict, and what Unicode's conformance tests use. |
Why use this over punycode and tr46
punycode only converts the Punycode encoding of each label. It doesn't map uppercase or compatibility characters and doesn't check the result, so punycode.toUnicode('xn--google-') returns google, and punycode.toASCII('ÜBER.COM') returns xn--BER-ska.COM, which no browser produces. tr46 implements UTS #46, but needs punycode, which prints a deprecation warning on Node 21 and later.
- All 6396 cases in Unicode's
IdnaTestV2.txt18.0.0 pass in Node and Chromium, and so do WPT's URL tests for IDNA. The output equals tr46 7.0.0's on those cases and 20,000 random domains. - It is 16 kB minified and gzipped with both functions, against 64 kB for tr46 with punycode. The tables are generated from the Unicode data files by
scripts/generate.ts. toUnicodeon 10 hostnames was 6.8× as fast as tr46 and as fast as punycode, which does no mapping or validation.toAsciiwas 5.8× as fast as tr46 and as fast as punycode.new URL()is about 1.2× faster thantoAsciiin Node, where it runs in C++.toAsciiandtoUnicodetake O(n log n) time on long labels. punycode and tr46 take about 15 s to encode a 40,000-character label, as Python's idna did before CVE-2024-3651.- Both functions throw errors instead of returning them. tr46's
toUnicodereturns{ domain, error }, and code that reads onlydomaindisplaysxn--google-asgoogle. - tr46's
toASCIIreturnsnullfor an invalid domain, wheretoAsciithrowsInvalidInputError. - Without
strict,toAsciilowercases ASCII input without decoding it, as the URL Standard now specifies, sotoAscii('xn--b.com')returns'xn--b.com'where tr46 returnsnull.toUnicodestill throws for it. - tr46's
toASCIIreturns'a b'for'a b', buttoAsciithrows for a space and the other characters that URL forbids in a host. - tr46's
useSTD3ASCIIRules,checkHyphensandverifyDNSLengthare one option here:strict: trueturns on all three. - tr46's
checkBidiandcheckJoinershave no equivalent, because both checks always run, as in the URL Standard. - tr46's
transitionalProcessingisn't supported, because UTS #46 deprecated it and browsers dropped it, sofaß.debecomesxn--fa-hia.de, notfass.de. - tr46's
ignoreInvalidPunycodeisn't supported, because the URL Standard doesn't use it. - punycode's
encodeanddecodehave no equivalent, because raw Punycode isn't exported.Idna.toAscii(label)andIdna.toUnicode('xn--' + label)convert one label and also validate it. - punycode's
toASCIIconverts the domain of an email address and leaves the local part as it is, buttoAsciithrows for@. Split the address at the last@and convert only the domain.
Where URL isn't exact
The URL Standard runs UTS #46 for hosts, but implementations lag behind it. On IdnaTestV2.txt, leaving out IPv4 and URL delimiters:
- Node 24's
new URL()andurl.domainToASCIIskip the bidi rule, so they accept0à.א. Their tables predate Unicode 18 and reject its new characters. - Node's
url.domainToUnicodereturns the labels it could decode and leaves the others, instead of the whole input as the standard says. - Chromium 141's
new URL()gives a different result for 255 of 6253 cases. It skips the joiner rules, so it accepts U+200C ZERO WIDTH NON-JOINER between two Latin letters, and it rejects some valid domains.
toAscii and toUnicode use their own tables, so they give one answer everywhere.
| @portable/idna | tr46 | punycode | node:url | new URL() | |
|---|---|---|---|---|---|
| Convert 10 hostnames to Unicode | 87,400 | 12,900 | 76,700 | 79,000 | – |
| Convert 10 domains to ASCII | 84,200 | 14,500 | 79,200 | 79,900 | 102,000 |
Operations per second from pnpm bench, higher is better, in Node 24.22.0, on Intel(R) Xeon(R) Processor @ 2.10GHz (4 cores). A dash means the library has no equivalent.
| @portable/idna | tr46 | punycode | node:url | new URL() | |
|---|---|---|---|---|---|
| Minified and gzipped | 16 kB | 64 kB | 1.5 kB | built in | built in |
| Runtime dependencies | 1 | 1 | 0 | – | – |
| Node built-ins used | none | none | none | Node only | none |
| TypeScript types | included | @types/tr46 | @types/punycode | – | – |
| Version compared | – | 7.0.0 | 2.3.1 | – | – |
Sizes cover the imports in the usage example, or the equivalent imports of each library, bundled for the browser by scripts/bundle-size.ts. Runtime dependencies count every package installed with the published package. A library that uses Node built-ins needs polyfills in a browser and the nodejs_compat flag in Cloudflare Workers.