btoa(String.fromCharCode(...new TextEncoder().encode(text))) Base64-encodes any string. It runs in every browser and in Node 16 and later, which have btoa too. TextEncoder turns the text into its UTF-8 bytes, String.fromCharCode makes one character per byte, and btoa encodes those characters.
const text = 'café';
btoa(String.fromCharCode(...new TextEncoder().encode(text)));
// 'Y2Fmw6k='
The tool below shows the same Y2Fmw6k=: café is 5 bytes of UTF-8 (63 61 66 c3 a9), written as 8 Base64 characters.
Why btoa on its own goes wrong
btoa doesn’t take text. It takes a “binary string”, one character per byte, so it only accepts characters up to U+00FF. That makes it fail in two ways:
btoa('café');
// 'Y2Fm6Q=='
btoa('🦆');
// InvalidCharacterError: Failed to execute 'btoa' on 'Window': The string to be encoded
// contains characters outside of the Latin1 range.
The first is the dangerous one, because nothing throws. é went in as the single Latin-1 byte e9, so Y2Fm6Q== holds 63 61 66 e9, which isn’t UTF-8. Python, Go, the base64 command and most servers will decode it to bytes that aren’t valid text. Paste it into the tool with Decode and you get hex, not café.
Older answers write btoa(unescape(encodeURIComponent(text))). It gives the same Y2Fmw6k=, but unescape is a deprecated legacy function.
Long strings
The spread passes every byte as a separate argument, and engines limit how many arguments a call can take. In Chrome and Node, a string of 1,000,000 bytes throws RangeError: Maximum call stack size exceeded. When the text may be long, build the binary string without the spread:
function toBase64(text) {
const bytes = new TextEncoder().encode(text);
return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''));
}
toBase64('café');
// 'Y2Fmw6k='
Node.js and newer browsers
Buffer encodes a string as UTF-8 by default, so it needs no extra step:
Buffer.from('café').toString('base64');
// 'Y2Fmw6k='
Use 'base64url' in place of 'base64' for the URL-safe alphabet without padding, the form JWTs use.
Recent browsers also have new TextEncoder().encode(text).toBase64(), which returns the same Y2Fmw6k= with no binary string at all. Node 22 doesn’t have it, so check that it exists before you rely on it.