Primate LogoPrimate

Client navigation

Import primate/navigate to navigate from browser code without a full page reload. It is useful for interactions that happen outside a link, such as a desktop notification click.

import navigate from "primate/navigate";

await navigate("/projects/42");

navigate() is available only in browser code. Call it from a hydrated frontend component, event handler, or other client-side module; do not call it while rendering on the server.

Destinations

Pass either a relative or absolute URL:

await navigate("projects/42");
await navigate("/projects/42?tab=activity");
await navigate("https://app.example.com/projects/42");

Relative URLs resolve from the current page. Same-origin destinations use Primate's SPA navigation: Primate fetches and updates the current frontend without a full document reload, updates browser history, and scrolls to a hash target when one is present.

External destinations use normal browser navigation instead:

await navigate("https://example.com");

This lets application code use the same method without checking whether a URL belongs to the current app.

Notification example

For example, navigate when a notification is clicked:

import navigate from "primate/navigate";

notification.addEventListener("click", () => {
  void navigate(`/projects/${project.id}`);
});

Use void when the caller does not need to wait for the navigation to finish.

Refreshing the current route

Use refresh to fetch fresh server data for the current pathname and query without changing the URL, adding a history entry, scrolling, or reloading the document:

import navigate from "primate/navigate";

const refreshed = await navigate.refresh();

The promise resolves to true after Primate applies the fresh route data and dispatches the navigation completion event. It resolves to false if the request fails, is superseded by another navigation, redirects to another URL, or does not return a successful Primate route response. In those cases, the current view, URL, and history remain unchanged.

Foreground navigation (including links, forms, and Back/Forward) takes priority over background refreshes. A refresh requested while navigation is in progress waits until it settles, then fetches the current route: the destination if navigation succeeded, or the original route if it failed. Requests during that time coalesce into one refresh. If navigation interrupts a running refresh, Primate discards its stale response and schedules a new refresh afterward. While a refresh is already running, additional requests coalesce into one trailing refresh. A promise for a refresh attempt interrupted by navigation resolves to false, even though Primate independently schedules a replacement refresh afterward. Calls queued during navigation share the result of the next attempt; if it is interrupted too, they likewise resolve to false.

refresh is distinct from navigating to the current URL. A same-URL navigate() call keeps its existing no-op behavior.

Primate automatically intercepts ordinary same-origin links after hydration, so you usually do not need to call navigate() from an anchor's click handler. For custom link handling, pass the click event as the optional second argument:

link.addEventListener("click", event => {
  void navigate(link.href, event);
});

For same-origin URLs, the event is prevented and the SPA navigation runs. For external URLs, the event is left untouched so the browser performs its normal link navigation.

Native forms

After hydration, Primate also enhances ordinary same-origin forms that target the current window. GET submissions encode successful controls in the query string without sending a request body. As in a native submission, this replaces any query string written directly in the form action. Repeated controls and the button that initiated submission are preserved.

Submit-button overrides such as formaction, formmethod, and formenctype are honored. Forms with another target, an external action, or an unsupported encoding remain under browser control. A handler that calls preventDefault() also opts that submission out of Primate's enhancement.

Frontend page responses are rendered even when they use an error status such as 400 or 422, allowing a route to display validation errors without a full-page load. JSON API responses are not treated as frontend pages; use fetch when an application needs to consume an API payload directly.

Use navigate.after() when application-wide browser behavior must run after Primate has mounted or updated a destination view. At that point the listener observes both the destination URL and its DOM:

import navigate from "primate/navigate";

const unsubscribe = navigate.after(({ source, url }) => {
  analytics.page(url, { source });
});

// Stop listening when the owning integration is disposed.
unsubscribe();

source is "navigate" for enhanced links and navigate(), "form" for enhanced form navigation, "history" for Back and Forward, or "refresh" for navigate.refresh(). A successful navigation invokes listeners exactly once. Failed, superseded, and full-document navigation does not invoke them.

Hash-only and shallow navigation do not invoke listeners because they do not mount or update a destination view. Back and Forward between shallow states likewise do not invoke them. Prefer your frontend's component lifecycle hooks for behavior local to one component.

Query strings and hashes

Query strings and hashes are preserved:

await navigate("/search?q=primate#results");

Navigating to a different query string on the current route performs an SPA navigation. Navigating to a hash on the current route updates history and scrolls to the matching element.

Same-origin route redirects preserve the complete destination, including its query string and fragment. Primate updates history with that destination and scrolls after the redirected view mounts. This applies to enhanced links and forms, including redirect chains. Cross-origin or opaque redirects are left to the browser.

Shallow navigation

Use shallow to update the query string without fetching, remounting the current view, or scrolling:

await navigate("/search?q=primate", { shallow: true });

The public request store updates immediately. Back and Forward update it again without fetching while traversal remains within the same rendered view. Use replace when the new URL should replace the current history entry:

await navigate("/search?q=framework", {
  replace: true,
  shallow: true,
});

Shallow navigation applies only when the destination has the current pathname. A different pathname performs normal SPA navigation.

See Frontends for frontend setup and the available hydrated frontend integrations.

Previous
Intro
Next
Angular