Now.js Framework Documentation
PageNavigator
PageNavigator
Overview
PageNavigator loads server-rendered pages (for example a CMS front end) into the main element (Now.config.mainSelector, normally #main) without a full reload, and updates the address bar as usual.
- A click on an internal link fetches the same URL with the header
X-Partial-Page: 1. The server answers with JSON holding only what changes. The navigator swaps the content of#main, updates the title and the page-specific meta tags, and callspushStatewith the URL. - Opening a URL directly, as a first visit or a search engine crawler does, still gets the full page from the server. SEO does not change.
- Anything unexpected makes the navigator load the full page instead. That covers a network error, an answer that is not JSON, a missing response header,
reload: trueand a leave guard saying no. A link therefore never does nothing.
Use it when: pages are built on the server (PHP) and you want SPA-like speed.
Do not use it when: the app renders client-side templates. Use RouterManager instead. Never enable both, because they intercept the same links.
Enabling
await Now.init({
router: {enabled: false},
navigator: {
enabled: true,
// Paths (relative to Now.basePath) loaded normally: a string prefix or a RegExp
exclude: ['admin/', 'api/']
}
});| Option | Default | Meaning |
|---|---|---|
enabled |
false |
Intercept links |
header |
'X-Partial-Page' |
Header sent to ask for a partial page; the response must carry it with 1 |
exclude |
[] |
Paths not intercepted (string prefix or RegExp) |
scrollToTop |
true |
A new page starts at the top. If the URL has #id, the page scrolls to that element instead |
focusContent |
true |
Moves focus to #main after loading, so screen readers continue from the new content |
progress |
true |
Progress bar along the top of the window (see Progress bar) |
transition |
false |
Cross-fades between pages with the View Transitions API. It only works in browsers that support the API and is skipped when the user prefers reduced motion |
Server contract
The request carries X-Partial-Page: 1. The server must answer with JSON and the header X-Partial-Page: 1. It should also send Vary: X-Partial-Page and Cache-Control: no-store, because the same URL can answer in two forms.
{
"content": "<div>…inner HTML of #main…</div>",
"title": "Page title",
"head": "<meta name=\"description\" content=\"…\" data-page-meta><link rel=\"canonical\" href=\"…\" data-page-meta>",
"styles": ["https://fonts.googleapis.com/css2?family=Kanit"],
"reload": false
}| Key | Meaning |
|---|---|
content |
HTML that replaces the inner HTML of #main (required) |
title |
document.title |
head |
Page-specific <head> tags, each marked data-page-meta. The current [data-page-meta] tags are removed and these are inserted. Full pages must mark the same tags with data-page-meta |
styles |
Stylesheets the page needs. Each is added once and never removed |
reload |
true means the server cannot answer partially (maintenance page, language switch …), so the page loads in full |
| anything else | Passed through in the page:loaded event (detail.data), for example the current menu |
If the server redirects (for example to a login page), fetch follows the redirect and the address bar shows the final URL.
Forms
A method="get" form, such as a search box, loads its result page the same way as a link. Following the rules of GET, the query of action is replaced by the form's fields, including the button that was pressed.
These forms are not touched:
- POST forms
- Forms handled by FormManager (
data-form) - Forms with a
target - Forms under
data-navigate="false"
Progress bar
While a page loads, a thin bar runs along the top edge of the window in two steps:
- When loading starts, it runs quickly to 80% and waits there.
- When the new page is in, it fills from wherever it got to up to 100% and fades out.
Most pages arrive in a moment, so the bar runs briefly and fills straight away. Tune it with these CSS variables in the theme's CSS:
:root {
--page-progress-color: var(--color-primary); /* colour */
--page-progress-height: 3px; /* thickness */
--page-progress-at: 0.8; /* where the first step waits (0-1) */
--page-progress-start: 0.5s; /* duration of the first step */
--page-progress-end: 0.2s; /* fill time, then fade time */
}- If the bar's colour would vanish into whatever is at the top of the window (for example a header in the same primary colour, with contrast below 2), the bar turns white or dark, whichever stands out.
The element is <div class="page-progress">. It has the class is-loading during the first step and is-done during the second.
- When a link turns into a normal page load, the bar stays at 80% until the page goes.
- If the page never goes (the link was a download), the loading state ends on its own within 3 seconds.
- Turn the bar off with
progress: false.
Links left to the browser
- The link or an ancestor has
data-navigate="false" - The link has
target(other than_self),downloadorrel="external" - Ctrl, Cmd, Shift or Alt is held, or the click is not the main mouse button
- The URL is on another origin, outside
Now.basePath, or ismailto:/tel: - The URL is a file: any extension other than
.php,.htmlor.htm(for example.pdf,.zip) - The URL is a
#idlink on the same page - The path matches
exclude
PageNavigator.isNavigable('/news/123'); // true
PageNavigator.isNavigable('/files/a.pdf'); // falseScripts in the new content
innerHTML never executes <script>, so PageNavigator runs the new scripts in order after the swap:
- Inline scripts run every time.
- A script with
srcthat is already on the page is not loaded again. DOMContentLoadedandloadlisteners that the new scripts add are called once all of them have run. Those events never fire again on this document. Older templates that usedocument.addEventListener('DOMContentLoaded', …)therefore keep working unchanged.
Components, elements and forms inside #main are destroyed and created through the same steps as RouterManager.render().
The site config that AppConfigManager bound to the page on the full load (data-text="data.web_title", data-attr="data-username:data.google_client_id" on social login buttons, data-if …) is bound to the new #main content too, through AppConfigManager.applyBindings(). Markup outside #main is left as it is.
Events
| Event | Source | Data |
|---|---|---|
route:changed |
EventManager |
{path, params: {}, query, hash, route: null, previous, isNotFoundPage, scroll: false}. This is the same event the router emits, so components that refresh on it (MenuManager, EventCalendar …) work unchanged |
page:loaded |
EventManager and document (CustomEvent) |
{url, previous, status, data}, where data is the whole JSON payload |
content:rendered |
EventManager |
Emitted by RouterManager.render() |
// A plain script that does not use EventManager
document.addEventListener('page:loaded', e => {
console.log('now showing', e.detail.url);
});Leave guard
const off = PageNavigator.beforeLeave(url => {
// false = do not swap; load the page in full instead, so the page's beforeunload runs
return !editor.hasUnsavedChanges();
});
off(); // unregisterThe guard applies to link clicks and to Back/Forward.
Back / Forward and scroll position
- The scroll position is kept in
history.state.scrollYand restored on Back/Forward. history.scrollRestorationis set tomanual, because the content arrives after a fetch. A page reload also restores the saved position.- PageNavigator positions the page itself. The
route:changedit emits carriesscroll: false, so ScrollManager does not scroll to#mainagain. - While a page loads,
bodyhas the classloadingand#mainhasaria-busy="true". The theme decides how that looks.
API
| Method | Description |
|---|---|
init(options) |
Starts the navigator. Now.init calls it with the navigator config |
navigate(url, {fromHistory, scrollY}) |
Loads a page partially and returns true when the content was swapped |
isNavigable(href) |
Whether the URL can be loaded partially |
beforeLeave(guard) |
Registers a leave guard and returns an unregister function |