Module 7: Url As State
Parse, validate and defaults
Overview
You've already decided what goes in the URL (lesson 5) and you know how to translate state ↔ URL (lesson 3). What's missing is the discipline that makes the URL box robust: the URL is user input, and you don't trust it. Unlike a useState, which only your code can write, the URL can be edited by anyone —the user by hand, an old link with a parameter that no longer exists, a typo (?page=2 that became ?page=2x), a shared link with garbage, or something malicious (?sort=DROP TABLE)—. If your parse trusts what it reads, that garbage enters your state directly: page: NaN breaks the pagination, an invented sort breaks the sorting, a nonexistent category empties the list. The defense is a robust parse that validates each parameter: it converts and sanitizes (positive integers), whitelists the values (only known sorts), trims the excessive, and restores the default for anything that doesn't comply. The rule, in one phrase: parsing the URL is validating it; the state that comes out of parse is always clean, no matter what happens in the address bar.
Connection with the module. It's the lesson of robustness, and it closes the mechanic's cycle. Lesson 3 gave you the "happy" parse (round-trip with good data); this one hardens it against bad data. It's indispensable precisely because the virtues of lesson 4 are real: if the URL is shared, saved and edited, then URLs you didn't generate will reach you, and they must not break the app. It sets up lesson 7 and the project: the parse you'll integrate with the router is the validated one from here, not the naive one. Boundary: validating the format of the params (number, whitelist) is the client's and is this lesson; validating business rules against the backend (does that category exist in the DB?) is the server's —here we sanitize the shape, we don't consult the remote truth—.
An analogy: checking the address before sending the package
An envelope reaches you with an address written by another person, and your job is to deliver the package. Do you trust blindly what it says? No: you check it first. If the postal code has letters where numbers go, you don't try to deliver to "house NaN"; you use the default or ask for clarification. If the city doesn't exist in your list of routes, you don't invent a path; you mark it as unknown and use the default route. If the address is suspiciously long or weird, you trim it or reject it. Only after checking it do you deliver.
Parsing the URL is exactly that. The address was written by someone else (the user, an old link, an attacker), so you validate it before using it: page=abc → not a number → default 1 (not "page NaN"); sort=hax → not in your list of valid orders → default relevance (not an invented order); a query of 10,000 characters → you trim it. The difference between a naive mail carrier and a robust one is that the robust one never delivers to a garbage address: it sanitizes it first. Your parse is your app's robust mail carrier.
Worked example: naive parse vs robust parse
Let's execute the two parses over a battery of "dirty" URLs —the ones a user, a typo or an attacker could produce— and see what each one lets into the state.
'use strict';
console.log('=== M7 L6: parse, validate and defaults ===\n');
const SORTS = ['relevance', 'price', 'price-desc']; // whitelist of valid values
// NAIVE parse: trusts the URL. Lets garbage into the state.
function parseNaive(search) {
const p = new URLSearchParams(search);
return {
query: p.get('q') || '',
sort: p.get('sort') || 'relevance',
page: Number(p.get('page') || '1'),
};
}
// ROBUST parse: validates and sanitizes each param. The URL enters; the state comes out clean.
function parseSafe(search) {
const p = new URLSearchParams(search);
// query: trims and limits the length (avoids kilometer-long queries)
const query = (p.get('q') || '').trim().slice(0, 64);
// sort: only if it's in the whitelist; if not, the default
const sortRaw = p.get('sort') || 'relevance';
const sort = SORTS.includes(sortRaw) ? sortRaw : 'relevance';
// page: integer >= 1; NaN, negatives or garbage -> 1
const pageRaw = Number.parseInt(p.get('page') || '1', 10);
const page = Number.isInteger(pageRaw) && pageRaw >= 1 ? pageRaw : 1;
return { query, sort, page };
}
const show = (s) => `{ query: ${JSON.stringify(s.query)}, sort: ${JSON.stringify(s.sort)}, page: ${s.page} }`;
// URLs a user (or an attacker, or a typo) could open:
const dirty = [
'?q=mouse&sort=price&page=2', // clean
'?page=abc', // non-numeric page
'?page=-5', // negative page
'?page=2.9', // decimal page
'?sort=hax', // sort outside the whitelist
'?sort=DROP+TABLE', // malicious sort
];
for (const u of dirty) {
console.log('URL: "' + u + '"');
console.log(' parseNaive: ' + show(parseNaive(u)));
console.log(' parseSafe: ' + show(parseSafe(u)));
console.log('');
}
console.log('>>> What each validation does:\n');
console.log(' page="abc" -> Number.parseInt -> NaN -> default 1 (no "NaN" in the pagination)');
console.log(' page="-5" -> integer but < 1 -> default 1 (no negative page)');
console.log(' page="2.9" -> parseInt truncates -> 2 (integer, not decimal)');
console.log(' sort="hax" -> not in the whitelist -> "relevance" (no invented order)');
console.log('\nThe rule: the URL is user input. parse() ALWAYS validates; the state comes out clean.');
What to expect. When you run the file with Node, the output is exactly this:
=== M7 L6: parse, validate and defaults ===
URL: "?q=mouse&sort=price&page=2"
parseNaive: { query: "mouse", sort: "price", page: 2 }
parseSafe: { query: "mouse", sort: "price", page: 2 }
URL: "?page=abc"
parseNaive: { query: "", sort: "relevance", page: NaN }
parseSafe: { query: "", sort: "relevance", page: 1 }
URL: "?page=-5"
parseNaive: { query: "", sort: "relevance", page: -5 }
parseSafe: { query: "", sort: "relevance", page: 1 }
URL: "?page=2.9"
parseNaive: { query: "", sort: "relevance", page: 2.9 }
parseSafe: { query: "", sort: "relevance", page: 2 }
URL: "?sort=hax"
parseNaive: { query: "", sort: "hax", page: 1 }
parseSafe: { query: "", sort: "relevance", page: 1 }
URL: "?sort=DROP+TABLE"
parseNaive: { query: "", sort: "DROP TABLE", page: 1 }
parseSafe: { query: "", sort: "relevance", page: 1 }
>>> What each validation does:
page="abc" -> Number.parseInt -> NaN -> default 1 (no "NaN" in the pagination)
page="-5" -> integer but < 1 -> default 1 (no negative page)
page="2.9" -> parseInt truncates -> 2 (integer, not decimal)
sort="hax" -> not in the whitelist -> "relevance" (no invented order)
The rule: the URL is user input. parse() ALWAYS validates; the state comes out clean.
Compare the two columns row by row. Clean URL (?q=mouse&sort=price&page=2): the two parses coincide —with good data, validating changes nothing—. That's the point: validation is invisible when everything is fine, and only acts when garbage arrives. That's why it's easy to forget (in development you never write broken URLs) and that's why it's dangerous.
?page=abc: the naive one does Number("abc") → NaN, and page: NaN enters the state. A NaN in the pagination breaks everything that touches it —page + 1 gives NaN, comparisons fail, the UI shows "page NaN of 5"—. The robust one does Number.parseInt("abc", 10) → NaN, detects that it's not an integer, and restores the default 1. The app stays alive.
?page=-5: the naive one accepts -5 (it's a valid number, technically), and now you have a "page minus five" that doesn't exist. The robust one validates pageRaw >= 1 and restores 1. ?page=2.9: the naive one leaves 2.9 (a decimal page); the robust one uses parseInt, which truncates to the integer 2. In both cases, the robust one guarantees what the pagination needs: an integer ≥ 1.
?sort=hax and ?sort=DROP TABLE: the naive one puts "hax" and "DROP TABLE" directly into the state. That breaks the UI (there's no "hax" order, so getVisibleProducts would receive an unknown sort) and, worse, propagates an untrusted value from the user into your app. The robust one uses a whitelist (SORTS.includes(...)): if the value isn't one of the ones you defined (relevance, price, price-desc), it restores relevance. The whitelist is the strongest defense for values of a known set: instead of trying to detect the bad (impossible to enumerate all the garbage), you accept only the good and reject everything else.
The conclusion, at the top right of each row: parseSafe always delivers a valid state —page integer ≥ 1, sort from the whitelist, query bounded—, regardless of what arrives in the URL. parseNaive delivers whatever, including garbage that blows up the UI later, far from the origin, where it's hard to debug.
Deeper: the four techniques of a robust parse
A robust parse combines four defenses, one per type of risk:
1. Convert with the right function. For integers, Number.parseInt(x, 10) (with the base 10 explicit, to not depend on heuristics) instead of Number(x): parseInt("2.9", 10) gives 2 (truncates to the integer), which is what the pagination needs. Always reconstruct the real type (lesson 3), but choose the conversion that imposes the correct shape.
2. Validate the range / the shape. Converting isn't enough: Number.parseInt("-5", 10) is a valid integer, but a negative page makes no sense. Add the domain check: Number.isInteger(pageRaw) && pageRaw >= 1. Each numeric field has a legitimate range; verify it.
3. Whitelist for known sets. When a parameter can only take values from a fixed set (sort, category), define that set and accept only its members: SORTS.includes(sortRaw) ? sortRaw : 'relevance'. It's safer than a blacklist (enumerating the forbidden), because you can't foresee all the possible garbage; you can enumerate the valid.
4. Bound the size. For free text (query), trim the length (slice(0, 64)) and clean the edges (trim()). It avoids kilometer-long URLs and protects whatever consumes that text afterward. Free text can't be whitelisted, but it can be limited.
And above all, the discipline common to the four: the default as safety net. For any value that doesn't pass validation, don't throw an error nor let the garbage through: restore the default. That connects with lesson 3 —the absence of a parameter means its default— and extends it: an invalid parameter also means its default. That way, parse is a total function: for any input (including none, including garbage), it returns a valid state.
URL (untrusted) robust parse state (always clean)
──────────────────── ───────────────────── ───────────────────────
?page=abc ──▶ convert + validate range ──▶ page: 1 (default, no NaN)
?sort=hax ──▶ whitelist ──▶ sort: 'relevance' (default)
?q=<10000 chars> ──▶ trim + slice(0, 64) ──▶ query trimmed
(missing) ──▶ default ──▶ default value
A scope note: this validation is of the format (is it an integer? is it in my list of sorts?). The business validation —does that category really exist in the backend? does that page have results?— is the server's, and its answer is server state (M4-M6): if you request ?category=peripherals&page=999 and the backend returns empty, that's handled by the data layer, not parse. Here we sanitize the shape of the input; the truth is validated by the backend.
Common mistakes
Trusting the URL as if it were your useState. What happens: parse reads the parameters and passes them directly to the state without validating. Why it happens: it's forgotten that the URL is editable by anyone —"I control what I write in the URL"—. How to detect it: bugs with page: NaN, empty lists from an invalid sort, crashes that only appear with old or shared links. How to fix it: treat the URL as untrusted input, the same way a backend would treat a request body. parse validates each parameter and restores defaults; the state that comes out is always clean.
Using a blacklist instead of a whitelist. What happens: an attempt is made to filter the "bad" sort values (if (sort === 'hax') sort = 'relevance'). Why it happens: it seems more direct to plug the known cases. How to detect it: each new garbage value that appears (?sort=xyz, ?sort=123) sneaks through, because the blacklist didn't foresee it. How to fix it: enumerate the valid (SORTS) and reject everything not there. You can't list all the possible garbage; you can list the three sorts your app supports. The whitelist is closed by design.
Throwing an error instead of restoring the default. What happens: for an invalid parameter, parse throws an exception or leaves the app on an error screen. Why it happens: "if the input is invalid, you have to fail". How to detect it: a link with a typo (?page=2x) breaks the whole page instead of showing page 1. How to fix it: for URL state, the robust thing is to degrade gracefully —restore the default and show something useful—, not blow up. The user who opened a half-broken link should see the nearest reasonable search, not an error. parse sanitizes; it doesn't reject the user.
Exercises
Exercise 1 — Validate a new parameter. Mercado adds perPage (results per page): integer, allowed only 10, 20 or 50, default 20. Write the validation line in parse and explain why a whitelist is better than "any positive integer".
See solution
const ALLOWED_PER_PAGE = [10, 20, 50];
const perPageRaw = Number.parseInt(p.get('perPage') || '20', 10);
const perPage = ALLOWED_PER_PAGE.includes(perPageRaw) ? perPageRaw : 20;
A whitelist ([10, 20, 50]) is better than "any positive integer ≥ 1" because perPage controls how many results the UI requests, and an arbitrary value would be a risk: ?perPage=100000 would make the frontend try to render a hundred thousand rows (or the backend return a giant response), a trivial abuse vector from the address bar. With the whitelist, only the three sizes your app supports are valid; anything else (including positive integers like 99999) falls to the default 20. When a parameter has a small, known set of legitimate values, enumerate them; don't accept "any of the right type".
Exercise 2 — Predict the output. Without running the code, say what parseSafe returns for: (a) ?page=0; (b) ?sort=price&page=3; (c) ?q=%20%20%20mouse%20%20. Justify each with the technique that applies.
See solution
- (a)
?page=0→{ query: '', sort: 'relevance', page: 1 }.Number.parseInt("0", 10)is0, an integer, but the range validation (>= 1) rejects it, so it restores the default1. Page zero doesn't exist; it degrades to the first. - (b)
?sort=price&page=3→{ query: '', sort: 'price', page: 3 }.priceis in the whitelist (SORTS), so it passes;3is an integer ≥ 1, so it passes. Valid data, transparent validation. - (c)
?q=%20%20%20mouse%20%20→{ query: 'mouse', sort: 'relevance', page: 1 }. The%20s are encoded spaces;URLSearchParams.get('q')unescapes them to" mouse ", andtrim()removes the edge spaces →"mouse"(andslice(0, 64)doesn't touch it since it's short). Free text is cleaned withtrim+slice.
Exercise 3 — Harden a naive parse. You're given this parse: { page: Number(p.get('page')), sort: p.get('sort') }. Enumerate at least three ways it breaks with real URLs and rewrite it robust (with defaults, range and whitelist).
See solution
Ways it breaks: (1) ?page missing → Number(null) → 0 (or Number(undefined) → NaN), without default; (2) ?page=abc → NaN, which blows up the pagination; (3) ?page=-5 or ?page=2.9 → accepts negatives and decimals; (4) sort missing → null (not a usable default); (5) ?sort=<anything> → passes without validating. The robust version:
const SORTS = ['relevance', 'price', 'price-desc'];
function parse(search) {
const p = new URLSearchParams(search);
const pageRaw = Number.parseInt(p.get('page') || '1', 10);
const sortRaw = p.get('sort') || 'relevance';
return {
page: Number.isInteger(pageRaw) && pageRaw >= 1 ? pageRaw : 1, // integer >= 1, or default
sort: SORTS.includes(sortRaw) ? sortRaw : 'relevance', // whitelist, or default
};
}
Now parse is total: for any URL (missing, with garbage, with out-of-range values) it returns a valid state —page integer ≥ 1 and sort from the whitelist—, restoring defaults instead of propagating garbage or throwing errors.
Summary and next step
In this lesson you hardened the parse: the URL is user input, editable by anyone, so parsing is validating. You contrasted, executed, a naive parse that lets garbage into the state (page: NaN, sort: "hax", sort: "DROP TABLE") against a robust parse that applies four techniques —convert with the right function (parseInt base 10), validate range (>= 1), whitelist (SORTS.includes), and bound size (trim + slice)— and, above all, restores the default for anything invalid. You understood that validation is invisible with good data (that's why it's forgotten) and critical with bad data, that the whitelist beats the blacklist, and that the robust thing is to degrade gracefully (default), not blow up. You saved the analogy of the mail carrier who checks the address before delivering, and the boundary: here we validate the format (client); the business validation against the DB is the server's.
Before moving on you should be able to: explain why the URL isn't trustworthy; write a robust parse with the four techniques; justify the whitelist over the blacklist; and decide to restore the default instead of throwing an error.
Lesson 7 closes the module by connecting everything with the tool: how React/Next's router integrates the URL as state. You're going to see the real API —useSearchParams to read the URL, useRouter (push/replace) to navigate— and model in Node the router's mechanic: a mini-router with its history stack that reads the URL and navigates, to see that its job is exactly the loop you've been using (parse → derive → serialize → navigate). And it marks the final boundary: the concept and URLSearchParams are yours (you mastered them here); Next's router, with its complete model, is the nextjs guide's.
Resources
- MDN, "Number.parseInt()" — developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseInt. The conversion to integer with an explicit base that the robust
parseuses (parseInt(x, 10)). In English. - MDN, "Number.isInteger()" — developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger. The shape check to validate that
pageis an integer before accepting it. In English. - OWASP, "Input Validation Cheat Sheet" — cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html. Why validate all untrusted input (the URL is) and why the whitelist beats the blacklist. In English.
- Zod, "Basic usage" — zod.dev. A schema-validation library many apps use to parse and validate
searchParamsdeclaratively; the next step after this lesson's by-hand robustparse. In English.