@absoluteui/helpers/url
Query strings, URL joining, scheme allow-lists, same-origin checks, and open-redirect protection.
import { ... } from '@absoluteui/helpers/url';How to use
Section titled “How to use”Safe building and reading of URLs and query strings, plus the checks that stop open redirects and javascript: links. Reach for it whenever a URL contains user input: a return address after sign-in, an ID in an API path, filters kept in the address bar, or a link someone typed into a profile. The HTTP guide shows how the services HTTP client builds request URLs on top of these helpers.
How do I build a safe redirect target after sign-in?
Section titled “How do I build a safe redirect target after sign-in?”Never redirect to a returnTo parameter as is. safeRedirectTarget keeps same-site paths and absolute URLs on the origins you allow; anything else (//evil.test, /\evil.test, other schemes) becomes the fallback.
import { getQueryParam, safeRedirectTarget } from '@absoluteui/helpers/url';
const options = { allowedOrigins: ['https://payroll.example.com'], fallback: '/home' };const back = (url: string) => safeRedirectTarget(getQueryParam(url, 'returnTo'), options);
back('https://hr.example.com/login?returnTo=%2Fleave%2Frequests%3Fstatus%3Dopen'); // => '/leave/requests?status=open'back('https://hr.example.com/login?returnTo=https%3A%2F%2Fpayroll.example.com%2Fpayslips'); // => 'https://payroll.example.com/payslips'back('https://hr.example.com/login?returnTo=%2F%2Fevil.test'); // => '/home'back('https://hr.example.com/login'); // => '/home' // parameter missingHow do I put an untrusted ID into an API path?
Section titled “How do I put an untrusted ID into an API path?”joinUrl handles the slashes but does not encode, so encode each untrusted segment with encodePathSegment. Then add the query with withQuery, which encodes values and skips undefined.
import { encodePathSegment, joinUrl, withQuery } from '@absoluteui/helpers/url';
const employeeId = '../admin';const path = joinUrl('https://api.example.com/v1/', 'employees', encodePathSegment(employeeId), 'payslips');path; // => 'https://api.example.com/v1/employees/..%2Fadmin/payslips' // the slash is encoded, so no traversalwithQuery(path, { year: 2026, month: undefined, status: ['issued', 'paid'] }); // => 'https://api.example.com/v1/employees/..%2Fadmin/payslips?year=2026&status=issued&status=paid'How do I keep list filters in the address bar?
Section titled “How do I keep list filters in the address bar?”Read the current filters with parseQuery (null-prototype, repeated keys become arrays), change one with withQuery, and clear them with removeQuery. Other parameters and the fragment are kept.
import { parseQuery, removeQuery, withQuery } from '@absoluteui/helpers/url';
const current = '/employees?dept=finance&dept=hr&page=3#results';parseQuery(current.split(/[?#]/)[1] ?? '').dept; // => ['finance', 'hr']withQuery(current, { page: 1, q: 'rao' }); // => '/employees?dept=finance&dept=hr&page=1&q=rao#results'removeQuery(current, ['dept']); // => '/employees?page=3#results'How do I show a link that a user typed?
Section titled “How do I show a link that a user typed?”Check it with isSafeUrl before it reaches href. The default allow-list is http, https, mailto, and tel; pass allowedSchemes to narrow it and allowRelative: false to require absolute URLs.
import { isSafeUrl } from '@absoluteui/helpers/url';
const profileLink = (url: string) => (isSafeUrl(url, { allowedSchemes: ['https'], allowRelative: false }) ? url : undefined);profileLink('https://www.linkedin.com/in/asha-rao'); // => 'https://www.linkedin.com/in/asha-rao'profileLink('javascript:alert(document.cookie)'); // => undefinedprofileLink('http://example.com'); // => undefined // not on the narrowed listprofileLink('/relative/path'); // => undefinedWhat goes wrong?
Section titled “What goes wrong?”Unsafe input fails loudly instead of producing a surprising URL. parseQuery caps length (64 KiB), parameter count (1000), and nesting depth (5) by default; lower them for untrusted input.
import { buildQuery, joinUrl, parseQuery } from '@absoluteui/helpers/url';
joinUrl('/files', '../etc/passwd', { confine: true }); // => throws HLP_URL_PATH_TRAVERSALbuildQuery(JSON.parse('{"__proto__":{"admin":true}}')); // => throws HLP_UNSAFE_KEYparseQuery('a=1&b=2&c=3', { maxParams: 2 }); // => throws HLP_URL_TOO_MANY_PARAMSparseQuery('a[b][c]=1', { maxDepth: 1 }); // => throws HLP_URL_TOO_DEEPAPI reference
Section titled “API reference”Functions
Section titled “Functions”buildQuery
Builds a query string (without ? unless prefix). Spaces encode as %20.
Nested objects use bracket notation. Unsafe keys (__proto__, …) throw HLP_UNSAFE_KEY.
function buildQuery(params: QueryInput, options?: BuildQueryOptions): string;Example:
buildQuery({ q: 'a b', tag: ['x', 'y'], page: 2 }); // => 'q=a%20b&tag=x&tag=y&page=2'buildQuery({ tag: ['x', 'y'] }, { arrayFormat: 'comma' }); // => 'tag=x,y'encodePathSegment
Percent-encodes one path segment, including /, ?, #; . and .. are encoded so they cannot traverse.
function encodePathSegment(segment: string | number): string;Example:
encodePathSegment('a/b c'); // => 'a%2Fb%20c'encodePathSegment('..'); // => '%2E%2E'getDomain
Hostname of an absolute URL (lowercase, no port); undefined when unparseable.
function getDomain(url: string): string | undefined;Example:
getDomain('https://Sub.Example.com:8080/x'); // => 'sub.example.com'getExtension
Lowercase file extension without the dot, from a URL or path; undefined when none (dotfiles have none).
function getExtension(urlOrPath: string): string | undefined;Example:
getExtension('/a/photo.JPG?x=1'); // => 'jpg'getExtension('.env'); // => undefinedgetFileNameFromUrl
Last path segment, percent-decoded; undefined when the path ends with /. Works for relative URLs.
function getFileNameFromUrl(url: string): string | undefined;Example:
getFileNameFromUrl('https://x.test/files/report%202026.pdf?v=1'); // => 'report 2026.pdf'getQueryParam
First value of a query parameter, decoded; undefined when absent.
function getQueryParam(url: string, key: string): string | undefined;Example:
getQueryParam('https://x.test/?a=1&a=2', 'a'); // => '1'isAbsoluteUrl
True for URLs with a scheme (https:, mailto:); protocol-relative //host is not absolute.
function isAbsoluteUrl(url: string): boolean;Example:
isAbsoluteUrl('https://a.test'); // => trueisAbsoluteUrl('/a'); // => falseisSafeUrl
True when url is safe to put in href/src: scheme on the allow-list (rejects javascript:,
data:, vbscript:), no control characters (blocks java\tscript:), and parseable.
function isSafeUrl(url: string, options?: IsSafeUrlOptions): boolean;Example:
isSafeUrl('https://example.com'); // => trueisSafeUrl(' JaVa\tScRiPt:alert(1)'); // => falseisSameOrigin
True when both absolute URLs have the same origin (scheme, host, port). Opaque origins never match.
function isSameOrigin(a: string | URL, b: string | URL): boolean;Example:
isSameOrigin('https://a.test/x', 'https://A.test:443/y'); // => truejoinUrl
Joins a base URL and path segments with exactly one / between parts. Keeps the base’s
protocol, authority, query and fragment. Pass { confine: true } last to block .. escapes.
Segments are not encoded: use encodePathSegment for untrusted values.
function joinUrl(base: string, ...parts: readonly (string | number | JoinUrlOptions)[]): string;Example:
joinUrl('https://api.example.com/v1/', '/users/', 42); // => 'https://api.example.com/v1/users/42'joinUrl('/files', '../etc', { confine: true }); // => throws HLP_URL_PATH_TRAVERSALnormalizeUrl
Normalizes an absolute URL: lowercase scheme and host, default port removed, dot segments resolved,
plus optional query sort and fragment removal. Returns undefined when unparseable.
function normalizeUrl(url: string, options?: NormalizeUrlOptions): string | undefined;Example:
normalizeUrl('HTTPS://Example.COM:443/a/../b?z=1&a=2#x', { sortQuery: true, removeFragment: true });// 'https://example.com/b?a=2&z=1'parseQuery
Parses a query string (leading ? optional; + decodes as a space) into null-prototype objects.
Supports repeated keys, a[]= arrays and a[b][c]= nesting. Parameters with unsafe keys
(__proto__, constructor, prototype) are dropped; conflicting shapes keep the first value.
function parseQuery(qs: string, options?: ParseQueryOptions): QueryObject;Example:
parseQuery('?q=a+b&tag=x&tag=y&user[name]=Ann'); // => { q: 'a b', tag: ['x', 'y'], user: { name: 'Ann' } }parseUrl
Parses a URL, returning undefined instead of throwing.
function parseUrl(url: string | URL, base?: string | URL): URL | undefined;Example:
parseUrl('not a url'); // => undefinedparseUrl('/a', 'https://x.test')?.href; // => 'https://x.test/a'removeQuery
Removes the given query keys (or the whole query when keys is omitted). Keeps the fragment.
function removeQuery(url: string, keys?: readonly string[]): string;Example:
removeQuery('/a?x=1&y=2', ['x']); // => '/a?y=2'removeQuery('/a?x=1#f'); // => '/a#f'safeRedirectTarget
Open-redirect guard. Accepts same-site paths starting with a single / and absolute
http(s) URLs whose origin is allowed; rejects //evil.com, /\evil.com, schemes, and control characters.
function safeRedirectTarget(target: string | null | undefined, options?: SafeRedirectOptions): string;Example:
safeRedirectTarget('/dashboard?tab=1'); // => '/dashboard?tab=1'safeRedirectTarget('//evil.com'); // => '/'withQuery
Returns url with params set: existing keys named in params are replaced, others kept;
an undefined value removes the key. Works with relative URLs and keeps the fragment.
function withQuery(url: string, params: QueryInput, options?: BuildQueryOptions): string;Example:
withQuery('/list?page=1&q=x#top', { page: 2 }); // => '/list?q=x&page=2#top'Interfaces
Section titled “Interfaces”BuildQueryOptions
interface BuildQueryOptions { readonly arrayFormat?: ArrayFormat; readonly skipNull?: boolean; readonly sort?: boolean; readonly prefix?: boolean;}IsSafeUrlOptions
interface IsSafeUrlOptions { readonly allowedSchemes?: readonly string[]; readonly allowRelative?: boolean; readonly maxLength?: number;}JoinUrlOptions
interface JoinUrlOptions { readonly confine?: boolean;}NormalizeUrlOptions
interface NormalizeUrlOptions { readonly sortQuery?: boolean; readonly removeFragment?: boolean; readonly removeTrailingSlash?: boolean;}ParseQueryOptions
interface ParseQueryOptions { readonly arrayFormat?: ArrayFormat; readonly maxParams?: number; readonly maxDepth?: number; readonly maxLength?: number;}QueryInput
interface QueryInput { readonly [key: string]: QueryInputValue;}QueryObject
Null-prototype object returned by parseQuery.
interface QueryObject { [key: string]: QueryValue;}SafeRedirectOptions
interface SafeRedirectOptions { readonly allowedOrigins?: readonly string[]; readonly fallback?: string;}ArrayFormat
type ArrayFormat = 'repeat' | 'brackets' | 'comma';QueryInputValue
Values accepted by buildQuery: primitives, arrays of primitives, and nested objects (bracket notation).
type QueryInputValue = QueryPrimitive | readonly QueryPrimitive[] | QueryInput;QueryPrimitive
A single query value. undefined is always skipped; null becomes an empty value unless skipNull.
type QueryPrimitive = string | number | boolean | bigint | Date | null | undefined;QueryValue
Parsed query value.
type QueryValue = string | string[] | QueryObject;