A useful URL query parameter editor needs to do more than change a value: it should handle duplicate keys, keep unrelated parameters and fragments, and report malformed input clearly. This tutorial builds a small TypeScript utility for adding, updating, and deleting query parameters with the browser’s URL and URLSearchParams APIs.
Key Takeaways
- Use
new URL()to parse an absolute URL andURLSearchParamsto work with its query. - Use
append()to add a parameter without removing existing keys; duplicate keys are valid. - Treat an update as changing one occurrence or deliberately replacing all occurrences—this editor updates a selected occurrence.
- Query edits preserve parameter meaning and order, but serialization may normalize the original encoding.
- Return a typed success-or-error result so callers can handle invalid URLs without crashing.
How URL query editing works
A URL can contain a path, query, and fragment, for example:
https://example.com/search?tag=red&tag=blue#results
The query contains two entries named tag; it is not necessarily a map with one value per key. URLSearchParams.get() returns the first value, while getAll() returns every value. append() adds another entry, and set() replaces the named parameter’s values. Because set() removes duplicate values for that name, it is not the right choice when an editor should update just one row.
The URL object exposes a live searchParams object. Editing those parameters updates the URL while leaving its path and fragment intact. The implementation below copies the entries into an ordered list, changes the requested entry, then writes the list back. That makes the duplicate-handling behavior explicit.
Implement a typed editor
The functions below accept absolute URLs. They return either the edited URL or an error message. updateParameter changes the selected zero-based occurrence of a key (the first occurrence by default). deleteParameter removes all occurrences unless a particular occurrence is supplied.
type QueryEntry = {
name: string;
value: string;
};
type EditResult =
| { ok: true; url: string }
| { ok: false; error: string };
type Mutation = (entries: QueryEntry[]) => string | undefined;
function editUrl(input: string, mutate: Mutation): EditResult {
let url: URL;
try {
// No base is supplied: relative URLs are rejected.
url = new URL(input);
} catch {
return {
ok: false,
error: "Enter a valid absolute URL, such as https://example.com/?q=hello.",
};
}
const entries: QueryEntry[] = [];
url.searchParams.forEach((value, name) => {
entries.push({ name, value });
});
const error = mutate(entries);
if (error) return { ok: false, error };
// Rebuild in order. The URL object retains its path and fragment.
url.search = "";
for (const { name, value } of entries) {
url.searchParams.append(name, value);
}
return { ok: true, url: url.href };
}
export function addParameter(
input: string,
name: string,
value: string,
): EditResult {
return editUrl(input, (entries) => {
entries.push({ name, value });
});
}
export function updateParameter(
input: string,
name: string,
value: string,
occurrence = 0,
): EditResult {
return editUrl(input, (entries) => {
if (!Number.isInteger(occurrence) || occurrence < 0) {
return "Occurrence must be a non-negative integer.";
}
let matchingOccurrence = 0;
for (const entry of entries) {
if (entry.name !== name) continue;
if (matchingOccurrence === occurrence) {
entry.value = value;
return;
}
matchingOccurrence += 1;
}
return `No occurrence ${occurrence} of parameter "${name}" exists.`;
});
}
export function deleteParameter(
input: string,
name: string,
occurrence?: number,
): EditResult {
return editUrl(input, (entries) => {
if (occurrence === undefined) {
// Filter removes every entry with this name; other entries keep their order.
const remaining = entries.filter((entry) => entry.name !== name);
entries.splice(0, entries.length, ...remaining);
return;
}
if (!Number.isInteger(occurrence) || occurrence < 0) {
return "Occurrence must be a non-negative integer.";
}
let matchingOccurrence = 0;
const index = entries.findIndex((entry) => {
if (entry.name !== name) return false;
if (matchingOccurrence === occurrence) return true;
matchingOccurrence += 1;
return false;
});
if (index === -1) {
return `No occurrence ${occurrence} of parameter "${name}" exists.`;
}
entries.splice(index, 1);
});
}
The success result can be displayed or passed to another part of the application; the error result can be shown beside the input. For example:
const result = updateParameter(
"https://example.com/search?tag=red&tag=blue&q=a%2Fb%20c#results",
"tag",
"green",
1,
);
if (result.ok) {
console.log(result.url);
} else {
console.error(result.error);
}
Expected output:
https://example.com/search?tag=red&tag=green&q=a%2Fb+c#results
The second tag changed; the first remained. The slash remains percent-encoded, while the space is serialized as +. The query still represents the same decoded value, a/b c, but its original byte-for-byte encoding is not preserved.
Check duplicates, blank values, and encoding
These cases are worth trying in a browser or a TypeScript test:
| Input | Operation | Expected result |
|---|---|---|
https://example.com/?tag=red&tag=blue | Update tag, occurrence 1, to green | https://example.com/?tag=red&tag=green |
https://example.com/?flag=&x=1 | Add mode=fast | https://example.com/?flag=&x=1&mode=fast |
https://example.com/?q=a%2Fb%20c | Add x=1 | Query value remains a/b c; serialization may use + for its space |
not a URL | Add x=1 | { ok: false, error: "Enter a valid absolute URL, such as https://example.com/?q=hello." } |
A bare query key such as ?flag is read as a key with an empty value. Rebuilding it with URLSearchParams typically serializes it as ?flag=. That distinction is usually irrelevant to query-value handling, but the textual URL is different.
For a browser utility, the supplied route may also be useful when you want to edit query parameters with TidyURLs. An online editor can help inspect or transform input, but application code should still validate its own inputs and enforce its own requirements.
Common Mistakes
- Using
set()when duplicates matter. It replaces the parameter’s values rather than editing one selected occurrence. - Assuming a query is a plain object. A plain object cannot represent repeated keys or their order.
- Comparing serialized strings as if encoding were preserved.
URLSearchParamsuses form-style query serialization; equivalent values can have different encodings, such as%20and+for spaces. - Dropping the fragment accidentally. Build on the parsed
URLrather than assembling a URL from only its origin and query. - Passing relative URLs without a base.
new URL(input)requires an absolute URL. If your interface supports relative references, choose and pass an explicit base URL instead. - Treating parsing as security validation. Successfully parsing a URL does not establish that it is safe to navigate to or fetch. Apply the validation appropriate to your application separately.
Best Practices
- Decide and document whether an edit targets the first matching key, a selected occurrence, or every matching key.
- Keep query entries as an ordered list when duplicate keys are meaningful.
- Handle the
EditResultbefore displaying or using the output. - Test duplicate keys, empty values, reserved characters, fragments, malformed input, and relative input.
- If exact textual preservation matters, avoid parsing and rebuilding the whole query: URL APIs may normalize its encoding and representation.
Conclusion
A typed query editor can stay small while handling the cases that trip up object-based approaches. Parsing with URL, editing ordered entries, and returning a clear result lets the caller preserve duplicates and unrelated URL components while making encoding behavior explicit.
References
- MDN: URL — parsing and working with URL components.
- MDN: URLSearchParams — query parameter methods and serialization behavior.
