History & URLs — Client-Side Routing Underneath
IntermediateThe History API changes the URL without a page load, and URLSearchParams parses query strings. Together they are what every client-side router is built on.
Overview
A single-page application changes what is on screen without asking the server for a new document, but the URL still has to change — otherwise the back button, bookmarks and sharing all break. pushState does exactly that, and popstate tells you when the user navigates with back or forward. React Router wraps this, but knowing the primitives explains why a refresh 404s without server configuration, and why putting state in the URL is usually better than putting it in a component.
pushState and popstate
Change the URL, render the new view, and listen for the back button.
// Change the URL without a request
history.pushState({ page: 2 }, '', '/problems?page=2')
history.replaceState({}, '', '/problems') // no new history entry
// The user pressed back or forward
window.addEventListener('popstate', (e) => {
render(e.state) // whatever you passed to pushState
})
// pushState does NOT fire popstate — render yourself after calling it
function navigate(url, state) {
history.pushState(state, '', url)
render(state)
}
// The refresh problem: /problems/two-sum has no file on the server.
// The server must return index.html for unknown paths, or a refresh 404s.
// (Vercel and Next handle this; a bare nginx config does not.)URLSearchParams
Never parse a query string by hand. This handles encoding, repeats and ordering.
const params = new URLSearchParams(location.search)
params.get('page') // '2' — always a string
params.getAll('topic') // ['arrays', 'graphs'] for repeats
params.has('difficulty')
params.set('page', '3')
params.delete('q')
params.toString() // 'page=3&topic=arrays'
// Building a URL properly, with encoding handled
const url = new URL('/problems', location.origin)
url.searchParams.set('q', 'two sum & more') // encoded correctly
url.toString() // '/problems?q=two+sum+%26+more'
// Reading into an object
Object.fromEntries(new URLSearchParams(location.search))The URL as State
Filters and pagination belong in the URL. It makes the view shareable, bookmarkable, and survivable across a refresh.
// State in a component: lost on refresh, cannot be shared
const [difficulty, setDifficulty] = useState('easy')
// State in the URL: survives refresh, shareable, back button works
function setFilter(key, value) {
const url = new URL(location.href)
value ? url.searchParams.set(key, value) : url.searchParams.delete(key)
url.searchParams.delete('page') // reset paging when filtering
history.pushState({}, '', url)
render()
}
// The test: if a student sends a teammate their current view,
// does the teammate see the same thing? If not, the state is in
// the wrong place.Key Points to Remember
- 1pushState changes the URL without a request; it does not fire popstate, so you render yourself
- 2popstate fires when the user presses back or forward, carrying the state you pushed
- 3Client-side routes 404 on refresh unless the server returns index.html for unknown paths
- 4URLSearchParams handles encoding, repeated keys and ordering — never parse a query string manually
- 5Filters and pagination belong in the URL so a view can be shared, bookmarked and survive a refresh
Interview Questions
Sign in to ask AriaHow does a single-page app change the URL without reloading the page?
Why does refreshing a client-side route sometimes give a 404, and how is it fixed?
Why put filter state in the URL rather than in component state?
Ask Aria about History & URLs — Client-Side Routing Underneath
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.