Parse, don't concatenate
Parse and build web addresses with the browser's URL and URLSearchParams APIs, no escaping bugs.
Backticks are the clean way to interpolate a string, until that string is a URL.
The moment it is, `${base}/users?id=${id}` looks right in the editor and works on the happy path, then breaks in production on the input you didn’t escape.
A URL only looks like a string: it’s a structured value with its own grammar and escaping rules, and the browser ships a parser that knows all of them, the one the WHATWG URL standard defines.
So hand the string to that parser instead of filling in a template.
You’ll build API URLs this way throughout the course.
By the end of this lesson you’ll parse any URL into its fields, build one with new URL() and URLSearchParams without escaping bugs, and know which of three encoding tools belongs in which position.
A URL is six fields, not one string
Section titled “A URL is six fields, not one string”Before you hand a URL to the parser, you need its vocabulary: every URL breaks down into a fixed set of named fields.
Each label in that diagram is a property you can read off a URL instance: url.protocol, url.hostname, url.pathname, and so on.
One field carries a subtlety the diagram can’t show.
Unlike the plain string url.search, url.searchParams is a live URLSearchParams view of the query, so mutating it rewrites url.search and url.href for you.
A URL also has username and password fields, which this course ignores: credentials don’t belong in a URL.
Parsing takes one constructor call.
Hand new URL() a string and read the fields straight off the instance.
const url = new URL('https://api.acme.com:8443/v1/invoices?status=paid#row-7');url.hostname; // 'api.acme.com'url.pathname; // '/v1/invoices'url.search; // '?status=paid'url.origin; // 'https://api.acme.com:8443'No import made that work: URL is a global in every runtime this course ships on.
Building URLs with new URL(input, base)
Section titled “Building URLs with new URL(input, base)”Reading a URL is half the job; building one is where concatenation hurts most.
The constructor’s second argument is built for this: new URL(input, base) resolves a relative path against a base URL, the standard shape for assembling an API URL.
const base = 'https://api.acme.com'; // from a validated env varconst url = new URL('/v1/invoices', base);url.href; // 'https://api.acme.com/v1/invoices'Notice what you didn’t do: check whether base ended in a slash or whether the path started with one.
The parser resolves the path against the base the way a browser resolves a link on a page, the same way every time.
That consistency replaces a familiar failure: trailing-slash drift, where the same logical URL, assembled from two slightly different inputs, produces two different strings. Your cache, your logs, and your access checks then see two URLs where you meant one.
const a = 'https://api.acme.com' + '/v1'; // 'https://api.acme.com/v1'const b = 'https://api.acme.com/' + '/v1'; // 'https://api.acme.com//v1'A stray slash on either side gives you a double slash. The two strings look equal at a glance but compare unequal everywhere it counts.
const a = new URL('/v1', 'https://api.acme.com').href; // 'https://api.acme.com/v1'const b = new URL('/v1', 'https://api.acme.com/').href; // 'https://api.acme.com/v1'Both inputs land on the identical href. The parser normalizes the slashes, so you stop thinking about them.
The parser normalizes two more things alongside the slashes: it lowercases the hostname and drops the default port, so :80 for http and :443 for https collapse to no port at all.
The next lesson leans on this, so two URLs for the same server normalize to the same origin string instead of differing on capitalization or a redundant port.
new URL() throws when the input can’t be parsed, and where the input comes from decides whether that’s what you want.
A base URL almost always comes from configuration, an env var or a constant, so a malformed one is a deploy bug, not a user error.
Let it fail fast and loud at boot rather than wrapping every construction in a try/catch that buries a misconfiguration in a vague 500 later.
Untrusted input is the exception.
When the string came from a user, a query param, a pasted link, or a redirect target, a malformed value is data you fully expected, so throwing is the wrong response.
Reach for URL.canParse(input, base), a static method that returns a boolean instead of throwing.
const next = searchParams.get('next') ?? '';if (URL.canParse(next)) { const target = new URL(next); // safe to use target}The rule splits by trust: new URL() for config you control, letting it throw; URL.canParse() before parsing anything a user could send.
URL.parse() is the same guard but returns the parsed URL or null instead of a boolean, so reach for it when you want the result in the same step.
URLSearchParams: building and parsing query strings
Section titled “URLSearchParams: building and parsing query strings”Write params.set('q', userInput) and you never have to escape a query value again: URLSearchParams owns the encoding so you don’t.
No more debugging a stray & that ate the rest of a query string.
It builds from four kinds of input.
const fromUrl = url.searchParams;const fromRecord = new URLSearchParams({ status: 'paid', limit: '20' });const fromString = new URLSearchParams('status=paid&limit=20');const fromEntries = new URLSearchParams([['tag', 'a'], ['tag', 'b']]);The live view off a URL instance, and the one you reach for most in app code: mutate it and url.href updates with it.
const fromUrl = url.searchParams;const fromRecord = new URLSearchParams({ status: 'paid', limit: '20' });const fromString = new URLSearchParams('status=paid&limit=20');const fromEntries = new URLSearchParams([['tag', 'a'], ['tag', 'b']]);From a plain record, the quickest shape when every key has a single value. The values are strings, since a query string has no notion of numbers.
const fromUrl = url.searchParams;const fromRecord = new URLSearchParams({ status: 'paid', limit: '20' });const fromString = new URLSearchParams('status=paid&limit=20');const fromEntries = new URLSearchParams([['tag', 'a'], ['tag', 'b']]);From a raw query string, with or without the leading ?. This is how you parse an incoming query: take search off a URL and read it as structured fields.
const fromUrl = url.searchParams;const fromRecord = new URLSearchParams({ status: 'paid', limit: '20' });const fromString = new URLSearchParams('status=paid&limit=20');const fromEntries = new URLSearchParams([['tag', 'a'], ['tag', 'b']]);From an array of entries, the only shape that can express a repeated key like tag=a&tag=b, since an object can’t hold two tag keys.
A query key can repeat: ?tag=a&tag=b is a valid filter for “tagged a or b”, a list rather than a single value.
Each operation comes in two forms: append adds a value and set replaces every value for a key; get returns the first value and getAll returns all of them.
Insertion order is preserved, so the string comes back out in the order you built it.
const params = new URLSearchParams();params.append('tag', 'a');params.append('tag', 'b');params.getAll('tag'); // ['a', 'b']params.get('tag'); // 'a' (just the first)params.toString() returns the finished query string, percent-encoded.
That encoding differs from what you’d reach for elsewhere in a URL, and the next section works through that one detail.
Why a space encodes as %20 or +
Section titled “Why a space encodes as %20 or +”The WHATWG URL standard uses different percent-encode sets for different positions in a URL, so the same character can get a different escape depending on where it sits.
Two cases of percent-encoding diverge:
- Path segments and the fragment encode a space as
%20, the setencodeURIComponentmatches. application/x-www-form-urlencodedquery strings, whatURLSearchParamsproduces and what an HTML form submits, encode a space as+. Since+means space coming back, a literal plus must be sent as%2B.
That second rule is where the trouble starts: encode with one model and decode with the other, and the round trip silently corrupts your data.
Watch a value containing a plus, a search for "a+b", go through both paths.
const encoded = new URLSearchParams({ q: 'a+b' }).toString(); // 'q=a%2Bb'const back = new URLSearchParams(encoded).get('q'); // 'a+b'URLSearchParams escapes the literal plus to %2B and reads it back as a plus. Encode and decode with the same model, and the value survives the round trip exactly.
const encoded = new URLSearchParams({ q: 'a b' }).toString(); // 'q=a+b'const back = decodeURIComponent('a+b'); // 'a+b' ← a space went in, a plus came outThe space became + on the wire, but decodeURIComponent reads + as a literal plus, so the value is corrupted. The two tools disagree about what + means, and the disagreement stays invisible until a value contains one.
Which encoder, which position
Section titled “Which encoder, which position”The %20-vs-+ split is the subtle case; the common one is simpler.
Three encoding tools each belong in exactly one place, and most bugs come from picking the wrong one, reaching for encodeURIComponent everywhere and double-encoding what URLSearchParams already handled.
| Tool | Escapes structural chars (:/?#&=)? | Use for |
|---|---|---|
encodeURI | preserved | a whole, already-trusted, well-formed URL string (rarely the right tool) |
encodeURIComponent | escaped | a single value going into a path segment |
URLSearchParams | handled for you | any query-string construction |
In short: a path segment gets encodeURIComponent, anything in the query goes through URLSearchParams, and whole-URL escaping is almost never what you want, so reach for new URL instead.
Bug classes the parser removes
Section titled “Bug classes the parser removes”String concatenation produces each bug below silently; new URL plus URLSearchParams removes the whole class at the source, not one instance at a time.
- Parameter injection.
`?q=${q}`withq = 'a&admin=true'smuggles a second parameter in, whileparams.set('q', q)escapes the&to%26so it stays part of the value. This is the security-relevant one: a user-chosen query key is a real attack surface. - Double-encoding.
encodeURIComponentover a valueURLSearchParamsalready escaped gives%2520where you meant%20, because the%itself gets encoded. - Path-traversal-shaped values. A raw
'../admin'interpolated into a path is read as path syntax by the server, not as a literal segment. - Unicode hostnames. The parser normalizes an IDN via Punycode (
münchenbecomesxn--mnchen-3ya); hand-templating ships a hostname DNS can’t resolve. - Trailing-slash drift. The double-slash bug from the constructor section.
Globals in every runtime
Section titled “Globals in every runtime”URL and URLSearchParams are import-free globals in every runtime this course ships on: the browser, the Node.js server, and the Vercel Edge runtime.
The same two lines work unchanged in a Server Component, a Route Handler, or a browser click handler.
The vocabulary carries into React too: Next.js’s useSearchParams() hands you a read-only URLSearchParams, the same object and methods you just learned.
Practice: build an encoded URL
Section titled “Practice: build an encoded URL”This exercise grades the round trip: the test parses your result back and checks that the + in 'a+b' survived.
Build the query by hand with encodeURIComponent and it fails, because the + decodes to a space.
Given a base URL and a record { q, tags }, return the fully-built URL string. Put q in as a single search param, and add each entry of tags as a repeated tag param (so tags: ['x', 'y'] becomes tag=x&tag=y). Use URLSearchParams — no manual escaping, no string concatenation.
The solution is short, so try it before opening it.
Show a solution
function buildSearchUrl(base, { q, tags }) { const url = new URL(base); url.searchParams.set('q', q); for (const tag of tags) { url.searchParams.append('tag', tag); } return url.href;}Use set for the single value, append in the loop for the repeated one, and url.href to read back the finished, encoded string.
External resources
Section titled “External resources”Two references worth keeping open, plus a parser you can paste any URL into to see the six fields for yourself.
The full property and method surface of the URL interface, including the fields this lesson skipped.
Every construction shape and the get / getAll / append / set methods, with runnable examples.
Paste any URL and watch it decompose into scheme, host, path, query, and fragment — the six fields made tangible.