Now.js Framework Documentation

Now.js Framework Documentation

RouterManager

EN 09 Nov 2026 03:28

RouterManager

Overview

RouterManager is the client-side routing system in Now.js Framework for SPA (Single Page Application).

When to use:

  • Need SPA routing
  • Need page navigation without reload
  • Need route guards
  • Need dynamic routes

Why use it:

  • ✅ Hash and History mode
  • ✅ Route parameters
  • ✅ Navigation guards
  • ✅ Lazy loading
  • ✅ Auto template loading
  • ✅ Scroll restoration

Basic Usage

Initialization

await RouterManager.init({
  mode: 'history',  // or 'hash'
  routes: [
    { path: '/', template: '/pages/home.html' },
    { path: '/about', template: '/pages/about.html' },
    { path: '/users/:id', template: '/pages/user.html' }
  ]
});
<!-- Regular links work automatically -->
<a href="/about">About</a>
<a href="/users/123">User Profile</a>

<!-- With query string in href -->
<a href="/search?q=hello&page=1">Search</a>

<!-- Programmatic navigation -->
<button onclick="RouterManager.navigate('/contact')">Contact</button>

RouterManager supports data-params and data-param-* attributes to pass query parameters through <a> links without writing JavaScript expressions.

data-params — Forward params from current URL

Reads query parameters from the current URL (window.location.search) and forwards them to the destination.

<!-- Forward only "parent" param from current URL -->
<!-- Current URL: /menus?parent=0_MAINMENU -->
<!-- Navigates to: /menu?parent=0_MAINMENU -->
<a href="/menu" data-params="parent">Add Menu</a>

<!-- Forward multiple params -->
<!-- Current URL: /menus?parent=0_MAINMENU&lang=en -->
<!-- Navigates to: /menu?parent=0_MAINMENU&lang=en -->
<a href="/menu" data-params="parent,lang">Add Menu</a>

<!-- Forward ALL params from current URL -->
<a href="/menu" data-params="*">Add Menu</a>

data-param-* — Add fixed params

Appends fixed query parameters regardless of the current URL.

<!-- Navigates to: /menus?parent=0_MAINMENU -->
<a href="/menus" data-param-parent="0_MAINMENU">Main Menu</a>

<!-- Multiple fixed params -->
<a href="/menus" data-param-parent="0_MAINMENU" data-param-lang="en">Main Menu</a>

Combining both

data-params (from URL) and data-param-* (fixed) can be combined. Fixed values take precedence.

<!-- Forward "lang" from URL, but always set type=sub -->
<a href="/menu" data-params="lang" data-param-type="sub">Add Sub-menu</a>

Configuration

Set through Now.init({router: {...}}) (anything left out takes its value from RouterManager.DEFAULT_CONFIG).

await Now.init({
  router: {
    enabled: true,
    mode: 'history',          // 'history' or 'hash'
    base: currentDir,         // omitted = detected (autoDetectBase)

    // Layout shared by every page — see Layout below
    layout: 'layouts/app.html',
    layoutOutlet: '[data-router-outlet]',

    notFound: {
      behavior: 'render',     // 'render' | 'redirect' | 'preserve'
      template: '404.html',
      title: 'Page Not Found'
      // layout: false        // omitted = router.layout
    },

    trailingSlash: {mode: 'remove'},

    routes: {
      '/': {template: 'index.html', title: 'Dashboard'},
      '/users/:id': {template: 'user.html', title: 'User'},
      '/login': {template: 'login.html', layout: false, requireGuest: true}
    }
  }
});

Page content goes into the main element, Now.config.mainSelector (default #main, [role="main"]), or into the outlet of the layout when the route has one.

Layout

A layout is what every page shares — sidebar, topbar, footer. Each page renders into the layout's outlet ([data-router-outlet]), and the layout is not rebuilt while the next page uses the same one. Components and api components in the layout are therefore not mounted again, and do not fetch again, on every page change (no menu flicker).

<!-- templates/layouts/app.html -->
<aside data-component="sidebar"></aside>
<div class="main-content">
  <header data-component="topbar"></header>
  <main class="content" data-router-outlet></main>
  <footer class="footer">...</footer>
</div>

Page templates then hold only the content of their page, without repeating the sidebar and topbar in every file.

layout value Effect
not set on the route uses router.layout
'layouts/app.html' template name, loaded the way a route template is
'#layout-app' element in the page, e.g. a <template id="layout-app"> in index.php (no extra request)
false / null no layout — the page fills the main element (login, register)

When the layout is rebuilt

  • The next route uses a different layout, e.g. /login (layout: false) → / with the app layout after signing in — the sidebar picks up the user who just signed in by itself.
  • Something wrote over the main element (e.g. AuthErrorHandler showing an error page) — before reusing a layout the router always checks that its outlet is still in the page (isConnected).

Things to know

  • A layout without an outlet makes the navigation fail (navigate() returns false and logs an error naming the missing selector) while the current page stays intact — the router checks before tearing anything down.
  • Data in the layout that used to be "fresh" because it was rebuilt on every page is no longer reloaded by itself, e.g. the user's name and avatar after editing the profile. Give the layout's api component a data-refresh-event and emit that event after saving.
  • After a page change only the new markup is translated (I18nManager.updateElements()); locale:changed is not emitted — it fires only when the language actually changes.
  • RouterManager.getContentElement() returns the element the current page was rendered into (the outlet or the main element).

Route Definition

{
  // URL path
  path: '/users/:id',

  // Template file
  template: '/pages/user.html',

  // API endpoint
  api: '/api/users/:id',

  // Route name
  name: 'user-profile',

  // Menu link that is active on this page (a detail page points at its list)
  // The page's API can send menu_path instead — see MenuManager.md#letting-the-api-choose-the-menu-item-menu_path
  menuPath: '/users',

  // Layout of this page (omitted = router.layout, false = none) — see Layout
  layout: 'layouts/app.html',

  // Metadata
  meta: {
    requiresAuth: true,
    title: 'User Profile'
  },

  // Before enter guard
  beforeEnter: (to, from) => {
    if (!isAuthenticated()) {
      return '/login';  // Redirect
    }
    return true;  // Allow
  }
}

API Reference

RouterManager.navigate(path, params?, options?)

Navigate to a route

Parameter Type Description
path string Target path. May carry its own ?query and #hash, and may start with the router base
params object Query parameters, merged over the query in path and written to the address bar
options.replace boolean Replace the current history entry instead of pushing one
options.force boolean Render even when the URL does not change
options.preserveQuery boolean Keep the current query when path has none
options.scroll boolean false keeps the scroll position (default scrolls to top)
options.source string How the navigation started, passed on to route:before. A link in a menu sends menu, the page the document opened on initial; the default is link

Returns: Promise<boolean>

RouterManager.navigate('/users/123');
RouterManager.navigate('/search', {q: 'john'});          // → /search?q=john
RouterManager.navigate('/search?page=2', {q: 'john'});   // → /search?page=2&q=john
RouterManager.navigate('/login', {}, {replace: true});

Navigating to the page you are on. A navigation whose path is the current path is
skipped when the resulting query — the query in path merged with params — equals the
current one (key order does not matter); only a changed #hash is applied. Any other
query renders the route again, so these two calls are the same URL and behave the same:

// on /verify
RouterManager.navigate('/verify', {id: 'A'});  // → /verify?id=A (renders)
RouterManager.navigate('/verify?id=A');        // on /verify?id=A: nothing to do
RouterManager.navigate('/verify', {id: 'A'}, {force: true});  // renders again

Before this was fixed only the query written inside path was compared, so
navigate('/verify', {id: 'A'}) on /verify returned without doing anything.

RouterManager.getParams()

Get route parameters

Returns: Object

// URL: /users/123
const params = RouterManager.getParams();
// { id: '123' }

RouterManager.getQuery()

Get query parameters

Returns: Object

// URL: /search?q=john&page=2
const query = RouterManager.getQuery();
// { q: 'john', page: '2' }

RouterManager.beforeEach(guard)

Global navigation guard

Parameter Type Description
guard function Guard function
RouterManager.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next('/login');
  } else {
    next();
  }
});

RouterManager.afterEach(hook)

After navigation hook

RouterManager.afterEach((to, from) => {
  document.title = to.meta.title || 'My App';
  trackPageView(to.path);
});

Events

Event When Triggered Detail
router:initialized init() completed none
route:before Before a route change starts {path, params, source} — source as in options.source. Back and forward do not emit it
route:changed Route change succeeded {path, params, query, hash, route, previous, isNotFoundPage}
route:guard:rejected A guard refused the route {reason, originalPath, redirectTo, timestamp}
route:notFound No route matched the path {path, params, redirectTo}
content:rendered New content placed in the DOM {path, main, container} — container is what was replaced (the layout outlet or the main element)
EventManager.on('route:changed', (event) => {
  console.log('Navigated to:', event.data.path);
});

SPA in a Subfolder

When the admin panel is served from a subdirectory (e.g. /nowjs-gcms/admin/) rather than the domain root, two problems arise with history-mode routing:

  1. Relative resource paths break when the browser is at a deep URL like /admin/widgets/facebook and reloads — js/main.js resolves relative to the current URL, not the admin root.
  2. currentDir detection via window.location.pathname returns the wrong directory on a deep route refresh.

Solution: PHP entry point + <base href> + currentDir from script src

admin/index.php — generate <base href> dynamically so it never needs to be hardcoded:

<?php
// Works at any install path, no hardcoding needed.
// $_SERVER['SCRIPT_NAME'] always points to the executed PHP file
// regardless of URL rewriting, so dirname() gives the correct base.
$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])), '/') . '/';
?>
<!DOCTYPE html>
<html>
<head>
  <base href="<?= htmlspecialchars($base, ENT_QUOTES) ?>">
  ...

admin/.htaccess — rewrite all non-file requests to index.php:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]

Root .htaccess — add an admin SPA rule before the public CMS catch-all, otherwise the CMS index.php intercepts deep admin routes first:

# Admin SPA routes must reach admin/index.php, not the public CMS
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^admin/(.*)$ admin/index.php [L,QSA]

# Public CMS catch-all (comes after the admin rule)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]

admin/js/main.js — detect currentDir from the script's own src attribute, not from window.location.pathname:

// ❌ Breaks on deep route refresh (e.g. /admin/widgets/facebook)
const currentDir = window.location.pathname.substring(
  0, window.location.pathname.lastIndexOf('/') + 1
);

// ✅ Always correct — reads the script src attribute directly
const mainScriptEl = document.querySelector('script[src*="js/main.js"]');
let currentDir;
if (mainScriptEl) {
  const scriptUrl = new URL(mainScriptEl.src);
  currentDir = scriptUrl.pathname.replace(/\/js\/main\.js$/, '') + '/';
} else {
  // Fallback for non-SPA or direct-load scenarios
  const currentPath = window.location.pathname;
  currentDir = currentPath.substring(0, currentPath.lastIndexOf('/') + 1);
}

Then pass currentDir to Now.init:

await Now.init({
  paths: {
    templates:    `${currentDir}templates`,
    components:   `${currentDir}components`,
    translations: `${currentDir}../language`
  },
  router: {
    base: currentDir,
    ...
  }
});

Dynamic Routes Pointing Outside the Templates Directory

By default, TemplateManager resolves template paths relative to paths.templates.
Now.resolvePath() strips all ../ sequences for security, so you cannot use relative traversal to escape the templates directory.

Widget pattern — templates in a sibling directory

If each widget lives in its own directory outside admin/ (e.g. Widgets/facebook/index.html),
build the path as a full http:// URL computed once after currentDir is known.
TemplateManager bypasses resolvePath entirely for absolute URLs:

// currentDir = '/nowjs-gcms/admin/'
// Strip the last path segment ("admin/") to get the project root, then append Widgets/
const widgetsDir =
  window.location.origin +
  currentDir.replace(/[^/]+\/$/, '') +   // → '/nowjs-gcms/'
  'Widgets/';
// widgetsDir = 'http://localhost/nowjs-gcms/Widgets/'

Use :module as a route parameter in the template path:

routes: {
  '/widgets/:module': {
    template: `${widgetsDir}:module/index.html`,
    // resolves to e.g. http://localhost/nowjs-gcms/Widgets/facebook/index.html
    title: '{LNG_Widget}',
    menuPath: '/widgets',
    requireAuth: true,
    beforeEnter: requireAdmin
  }
}

Directory layout:

nowjs-gcms/
├── admin/
│   ├── index.php        ← SPA entry point with dynamic <base href>
│   ├── .htaccess        ← rewrites all routes to index.php
│   ├── js/main.js
│   └── templates/       ← normal admin templates
└── Widgets/
    ├── facebook/
    │   └── index.html   ← loaded via widgetsDir
    ├── textlinks/
    │   └── index.html
    └── share/
        └── index.html

Security: TemplateManager.validateRequest() enforces same-origin by default.
allowedOrigins is automatically seeded with window.location.origin on init,
so cross-domain template URLs are rejected even if the http:// prefix is used.

Common Pitfalls

⚠️ 1. History Mode Requires Server Config

# Apache (.htaccess)
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>
# Nginx
location / {
  try_files $uri $uri/ /index.html;
}

⚠️ 2. Guard Must Call next()

// ❌ Forgot to call next()
RouterManager.beforeEach((to, from, next) => {
  console.log('Guard');
  // Navigation stuck!
});

// ✅ Always call next()
RouterManager.beforeEach((to, from, next) => {
  console.log('Guard');
  next();
});

⚠️ 3. Root .htaccess catch-all intercepts SPA subroutes

If the SPA sits inside a subdirectory and the root .htaccess has a catch-all rule,
it will intercept requests like /admin/widgets/facebook before admin/.htaccess can run.

Always place a specific admin rule before the public catch-all:

# ✅ Admin rule first
RewriteRule ^admin/(.*)$ admin/index.php [L,QSA]

# Public CMS catch-all after
RewriteRule ^(.*)$ index.php [L,QSA]

⚠️ 4. ../ in template paths is stripped

Now.resolvePath() removes all ../ sequences as a security measure.
Use an absolute http:// URL (computed from window.location.origin) instead:

// ❌ Stripped by resolvePath — resolves to wrong path
template: `${currentDir}../Widgets/:module/index.html`

// ✅ http:// bypasses resolvePath entirely
const widgetsDir = window.location.origin + currentDir.replace(/[^/]+\/$/, '') + 'Widgets/';
template: `${widgetsDir}:module/index.html`