How-toVerified · string-utils 0.1.2· 2026-08-18beginner~8 min
@hyperfrontend/string-utils

How to base64 text that survives Unicode and URLs

You will encode any string, emoji and accents included, into base64 that round-trips exactly, and into a URL-safe form you can drop into a query parameter without escaping it again.

btoa reads a string as Latin-1. Anything above U+00FF throws InvalidCharacterError, and anything inside Latin-1 is worse: btoa('café') returns 'Y2Fm6Q==', which a UTF-8 decoder on the other end reads as 'caf�'. The standard alphabet then adds its own problem, because + and / are characters a URL escapes behind your back. @hyperfrontend/string-utils settles both, with one set of names that behaves identically in the browser and in Node.

1. Install it and import the side you run on

npm install @hyperfrontend/string-utils

Import from /browser in a page or worker, from /node in a server or CLI. Both export the same names with the same signatures and produce byte-identical output, so code that has to run in both places changes only its import line.

2. Encode and decode text

toBase64 encodes through UTF-8 first, so every code point survives:

import { toBase64, fromBase64 } from '@hyperfrontend/string-utils/browser'

toBase64('café') // 'Y2Fmw6k='
toBase64('日本語テキスト') // '5pel5pys6Kqe44OG44Kt44K544OI'
toBase64('🐟 koi 🐟') // '8J+QnyBrb2kg8J+Qnw=='

fromBase64('8J+QnyBrb2kg8J+Qnw==') // '🐟 koi 🐟'

fromBase64 accepts either alphabet and repairs missing padding, so you can decode a value without knowing which form produced it.

3. Make it safe to put in a URL

Pass true as the second argument. + becomes -, / becomes _, and the trailing = is dropped:

const state = JSON.stringify({ q: 'koi fish', filters: ['size>3', 'colour=orange'], page: 2 })

toBase64(state)
// 'eyJxIjoia29pIGZpc2giLCJmaWx0ZXJzIjpbInNpemU+MyIsImNvbG91cj1vcmFuZ2UiXSwicGFnZSI6Mn0='

toBase64(state, true)
// 'eyJxIjoia29pIGZpc2giLCJmaWx0ZXJzIjpbInNpemU-MyIsImNvbG91cj1vcmFuZ2UiXSwicGFnZSI6Mn0'

The URL-safe form passes through encodeURIComponent and URLSearchParams untouched; the standard form comes back percent-escaped. Keep the padding with a third argument of true when a decoder downstream insists on a length that is a multiple of four.

4. Encode bytes rather than text

For a Uint8Array you already hold, a ciphertext or a hash, go through the byte-level pair instead. uint8ArrayToBase64 takes the same two flags, and base64ToUint8Array reads either alphabet back:

import { uint8ArrayToBase64, base64ToUint8Array } from '@hyperfrontend/string-utils/browser'

uint8ArrayToBase64(new Uint8Array([0, 1, 2, 250, 251, 255])) // 'AAEC+vv/'
base64ToUint8Array('AAEC+vv/') // Uint8Array(6) [0, 1, 2, 250, 251, 255]

utf8StringToUint8Array and uint8ArrayToUtf8String cross between the two worlds when a payload is text on one side of a boundary and bytes on the other.

Check it worked

Round-trip a string carrying an emoji, an accent, and an embedded null through toBase64 and fromBase64, and compare with ===. Encode the same string with btoa and watch it throw. Then put the URL-safe form through URLSearchParams and read it back: the value that comes out is character for character the one you put in, and decoding it needs no unescaping step.

Reference and background

Next steps