Appearance
Reactive Page URL
When to use
When your application needs to react to URL changes — whether from user navigation, programmatic updates, or back/forward button presses — and you want a simpler interface than using the Navigation API directly.
The older History API (pushState, replaceState, popstate) cannot do this well. The popstate event only fires on back/forward traversals — it does not fire when you call pushState() or replaceState(). So to react to all URL changes from a single place, you would need to monkey-patch the History API or intercept every call site manually.
The Navigation API solves this: its navigatesuccess event fires for all navigation types — push, replace, and traversal. But the API is designed for full SPA routing (intercepting navigations, managing focus, controlling scroll restoration) and is more surface area than you need if all you want is to push URLs and subscribe to changes.
This pattern wraps the Navigation API into a minimal abstraction — PageURL — that provides push(), replace(), and a "change" event, with built-in deduplication so you only get notified when the URL actually changes. It also includes Vue composables (usePageURL and useURLParam) for reactive framework integration, though the core PageURL class is framework-agnostic.
The pattern
Using PageURL
PageURL is a static class that wraps the Navigation API. You subscribe to URL changes with PageURL.addEventListener("change", ...), navigate with push() and replace(), and generate derivative URLs with deriveURL():
js
import { PageURL } from "./navigation.js";
// React to any URL change
PageURL.addEventListener("change", (event) => {
console.log("Navigated to:", event.detail.href);
});
// Push a new history entry
PageURL.push("/dashboard");
// Replace current entry (no new history entry)
PageURL.replace("/settings");Generating derivative URLs with deriveURL
deriveURL builds a new URL from the current page URL by applying a modifier function. This is useful for generating links that need to stay in sync with the current URL — for example, adding or toggling a search parameter:
js
import { PageURL } from "./navigation.js";
// Generate a URL that adds a query parameter to the current page
const printableHref = PageURL.deriveURL((url) => {
url.searchParams.set("printable", "");
});
// Generate a URL that changes the path but preserves query params
const settingsHref = PageURL.deriveURL((url) => {
url.pathname = "/settings";
});Because deriveURL derives from the current URL, you can listen for "change" events and regenerate links whenever the page URL changes. This keeps navigation controls in sync without manually tracking which parts of the URL changed:
js
import { PageURL } from "./navigation.js";
function updateNav() {
settingsLink.href = PageURL.deriveURL((url) => {
url.pathname = "/settings";
});
printLink.href = PageURL.deriveURL((url) => {
url.searchParams.set("printable", "");
});
}
// Regenerate links on every navigation
PageURL.addEventListener("change", updateNav);
// Also generate them on initial load
updateNav();Implementing PageURL
PageURL is a static class — there is exactly one page URL, just as there is one window.location and one window.history. The constructor throws to enforce this. Internally, a private EventTarget handles event dispatch (since EventTarget requires an instance, but the class itself is never instantiated):
js
class PageURL {
static {
window.navigation.addEventListener("navigatesuccess", navigateHandler);
function navigateHandler() {
const newHref = window.navigation.currentEntry.url;
if (newHref === PageURL.#href) {
return;
}
PageURL.#href = newHref;
const event = new CustomEvent("change", {
detail: {
href: newHref,
url: new URL(newHref),
},
});
PageURL.#target.dispatchEvent(event);
}
}
constructor() {
throw new Error(
"PageURL cannot be instantiated. Use static methods.",
);
}
static #href = window.navigation.currentEntry.url;
static #target = new EventTarget();
static get href() {
return PageURL.#href;
}
static push(url) {
window.history.pushState(null, "", toHref(url));
}
static replace(url) {
window.history.replaceState(null, "", toHref(url));
}
static deriveURL(modifierFn = (url) => url) {
const newUrl = new URL(PageURL.#href);
modifierFn(newUrl);
return newUrl.href;
}
static addEventListener(...args) {
PageURL.#target.addEventListener(...args);
}
static removeEventListener(...args) {
PageURL.#target.removeEventListener(...args);
}
}
function toHref(url) {
if (typeof url === "string") {
return encodeURI(url);
} else if (url instanceof URL || url instanceof Location) {
return encodeURI(url.href);
} else {
throw new TypeError(
"url must be a string, URL, or Location.",
);
}
}Key design decisions:
- Static class. There is exactly one page URL, so
PageURLis a static class — the same pattern aswindow.locationandwindow.history. A privateEventTargetinstance handles event dispatch internally, sinceEventTargetrequires instantiation, but this is an implementation detail hidden behindaddEventListenerandremoveEventListener. - Navigation API for change detection, History API for mutations. The
navigatesuccessevent fires for all navigation types: push, replace, and back/forward. Thepush()andreplace()methods usehistory.pushState()andhistory.replaceState()rather thannavigation.navigate()because they do exactly what's needed — update the URL and history — without the additional ceremony of the Navigation API'sNavigationResultpromise and interception model. - Change detection. The handler compares the new href against the stored one and only emits when the URL actually changed. This deduplicates events from navigations that resolve to the same URL.
Using usePageURL
usePageURL is a Vue composable that returns a reactive ref tracking the current URL. An optional selector function transforms the URL before returning it, so you can derive computed values that update automatically on every navigation. Below, ariaCurrent recomputes whenever the URL changes, setting "page" on links whose to prop matches the current path:
vue
<script setup>
import { usePageURL } from "./navigation.js";
import { pathToRegexp } from "path-to-regexp";
const props = defineProps({ to: String });
const ariaCurrent = usePageURL((url) => {
const re = pathToRegexp(props.to, null, { end: false });
return re.exec(url.pathname) ? "page" : false;
});
</script>
<template>
<a :href="to" :aria-current="ariaCurrent">
<slot />
</a>
</template>You can also use usePageURL to read a single search parameter reactively:
vue
<script setup>
import { usePageURL } from "./navigation.js";
const returnTo = usePageURL((url) => {
return url.searchParams.get("returnTo") ?? "/home/";
});
</script>Implementing usePageURL
usePageURL subscribes to PageURL.addEventListener("change", ...") and stores the current URL in a ref. A computed wraps the ref, cloning the URL before passing it to the selector so one consumer can't mutate state seen by another:
js
import { computed, ref } from "vue";
function usePageURL(selectorFn = (url) => url) {
const url = ref(getURL());
PageURL.addEventListener("change", () => {
url.value = getURL();
});
return computed(() => {
const clonedURL = new URL(url.value);
return selectorFn(clonedURL);
});
function getURL() {
return new URL(PageURL.href);
}
}Using useURLParam
useURLParam is a higher-level composable for toggling a single URL search parameter. It returns a reactive object with isSet, value, set(), and unset() — where set() and unset() each return a reactive { href, go } pair. You can bind href to a link for declarative navigation, or call go() for imperative navigation:
vue
<script setup>
import { useURLParam } from "./navigation.js";
const printable = useURLParam("printable");
</script>
<template>
<a
v-if="!printable.isSet"
replace
:href="printable.set().href"
>
Printable Version
</a>
<a
v-if="printable.isSet"
replace
:href="printable.unset().href"
>
Normal Version
</a>
</template>Implementing useURLParam
useURLParam builds on usePageURL — it creates two selector-based refs for isSet and value, then provides set() and unset() methods that each compute the new URL and expose it as a reactive href with a go() method that calls PageURL.replace():
js
import { computed, reactive, unref } from "vue";
function useURLParam(key) {
const isSet = usePageURL(
(url) => url.searchParams.has(key),
);
const value = usePageURL(
(url) => url.searchParams.get(key),
);
function set(newValue) {
const newURLRef = usePageURL((url) => {
url.searchParams.set(key, newValue ?? "");
return url.href;
});
return reactive({
href: newURLRef,
go() {
PageURL.replace(unref(newURLRef));
},
});
}
function unset() {
const newURLRef = usePageURL((url) => {
url.searchParams.delete(key);
return url.href;
});
return reactive({
href: newURLRef,
go() {
PageURL.replace(unref(newURLRef));
},
});
}
return reactive({ isSet, value, set, unset });
}Declarative history replacement
A small plugin intercepts clicks on links with a replace attribute and calls PageURL.replace() instead of the browser's default navigation. This lets you write declarative replace-navigation in HTML without any JavaScript at the call site:
html
<a href="/settings" replace>Settings</a>Implementing declarative replace
The plugin registers a single click listener on the document that catches clicks on <a> elements with both href and replace attributes. It prevents the default navigation and calls PageURL.replace() with the link's href:
js
document.addEventListener("click", (event) => {
const link = event.target.closest("a[href][replace]");
if (link) {
event.preventDefault();
PageURL.replace(link.href);
}
});Trade-offs
- Navigation API browser support. The Navigation API became available across all major browsers in January 2026: Chrome 102+ (since 2022), Safari 26.2+ (December 2025), and Firefox 147+ (January 2026). Earlier versions of these browsers do not support it. For broader support, use a polyfill like
@virtualstate/navigation. Note that Firefox's implementation has some spec deviations around event ordering and state consistency — the spec itself is still maturing in these areas. - No third-party dependencies. The pattern uses a private
EventTargetinstance internally for event dispatch. If your codebase already useseventemitter3, you can swap it forEventEmitterto get a simpler emit/on/off API. - Vue coupling in composables. The composables use Vue's
ref,computed, andreactive. The corePageURLclass is framework-agnostic and can be used with any event listener pattern.