Now.js Framework Documentation

Now.js Framework Documentation

SPA in a Subfolder — Complete Setup Guide

EN 22 Feb 2026 02:36

SPA in a Subfolder — Complete Setup Guide

How to host a Now.js admin SPA inside a subdirectory (e.g. /nowjs-gcms/admin/)
while the public CMS lives at the domain root.

Problem Statement

Running a history-mode SPA from a subfolder breaks in three ways:

Symptom Root cause
CSS/JS 404 on deep-route refresh (/admin/widgets/facebook) No <base href> → browser resolves relative paths from the current URL
currentDir is wrong on deep-route refresh window.location.pathname returns /admin/widgets/facebook, not /admin/
Admin deep-routes return the public CMS page Root .htaccess catch-all intercepts admin/* before admin/.htaccess runs

Step 1 — PHP entry point with dynamic <base href>

Convert admin/index.htmladmin/index.php.
PHP generates <base href> from $_SERVER['SCRIPT_NAME'], which always points
to the executed script regardless of URL rewriting.

<?php
// Works at any install path — no hardcoding required.
// dirname($_SERVER['SCRIPT_NAME']) = /nowjs-gcms/admin
$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])), '/') . '/';
?>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <base href="<?= htmlspecialchars($base, ENT_QUOTES) ?>">
  <title>Admin Panel</title>
  <!-- All src/href attributes below are now relative to the base -->
  <link rel="stylesheet" href="css/app.css">
</head>
<body>
  <div id="app"></div>
  <script src="../Now/dist/now.core.min.js"></script>
  <script src="js/main.js"></script>
</body>
</html>

Why $_SERVER['SCRIPT_NAME'] instead of $_SERVER['REQUEST_URI']:

Variable Value when visiting /admin/widgets/facebook
$_SERVER['REQUEST_URI'] /nowjs-gcms/admin/widgets/facebook ← wrong
$_SERVER['SCRIPT_NAME'] /nowjs-gcms/admin/index.php ← always correct

Step 2 — admin/.htaccess

Rewrite all non-file, non-directory requests to index.php (the SPA shell):

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

Step 3 — Root .htaccess

The root .htaccess is processed before subdirectory rules.
A generic catch-all (^(.*)$ index.php) would intercept /admin/widgets/facebook
and serve the public CMS instead of the admin SPA.

Add a specific admin rule above the public catch-all:

<IfModule mod_rewrite.c>
  RewriteEngine On

  # 1. Static files — serve as-is
  RewriteCond %{REQUEST_FILENAME} -f [OR]
  RewriteCond %{REQUEST_FILENAME} -d
  RewriteRule ^ - [L]

  # 2. Admin SPA — must come BEFORE the public CMS catch-all
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule ^admin/(.*)$ admin/index.php [L,QSA]

  # 3. Public CMS catch-all
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule ^(.*)$ index.php [L,QSA]
</IfModule>

Step 4 — currentDir detection in main.js

window.location.pathname returns the current URL path, not the script directory.
On a deep-route refresh it is wrong. Read the script's own src attribute instead:

// ❌ Breaks on /admin/widgets/facebook refresh
const currentDir = window.location.pathname.substring(
  0, window.location.pathname.lastIndexOf('/') + 1
);
// → '/nowjs-gcms/admin/widgets/'  ← wrong

// ✅ Always the directory containing main.js
const mainScriptEl = document.querySelector('script[src*="js/main.js"]');
let currentDir;
if (mainScriptEl) {
  const scriptUrl = new URL(mainScriptEl.src);
  // Remove '/js/main.js' suffix → keep '/nowjs-gcms/admin/'
  currentDir = scriptUrl.pathname.replace(/\/js\/main\.js$/, '') + '/';
} else {
  // Fallback for edge cases
  const p = window.location.pathname;
  currentDir = p.substring(0, p.lastIndexOf('/') + 1);
}

Pass currentDir to Now.init:

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

Step 5 — Templates outside admin/ (Widget pattern)

Now.resolvePath() strips all ../ sequences for security — you cannot escape
paths.templates using relative traversal.

Compute a full http:// URL for directories outside admin/:

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

Use it in route config with the :module parameter:

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

Widget directory structure:

nowjs-gcms/
├── admin/
│   ├── index.php          ← SPA shell (PHP, dynamic <base href>)
│   ├── .htaccess          ← rewrites to index.php
│   ├── js/
│   │   └── main.js        ← currentDir + widgetsDir computed here
│   └── templates/         ← normal admin templates
└── Widgets/
    ├── facebook/
    │   └── index.html
    ├── textlinks/
    │   └── index.html
    └── share/
        └── index.html

Why http:// URLs bypass resolvePath — two-bug story

When the http:// URL was first used, it triggered two separate bugs in TemplateManager:

Bug 1 — else if prepend ignored absolute URLs

// Old code (buggy)
} else if (Now.config?.paths?.templates
    && !path.trim().startsWith('<')
    && !path.startsWith(Now.config.paths.templates)) {  // ← no http:// check
  path = Now.config.paths.templates + path;
  // → '/nowjs-gcms/admin/templates' + 'http://...'
  // → 'templateshttp://...'  ← wrong!
}

Fix: add !/^(https?:)?\/\//i.test(path) to the condition so absolute URLs are left untouched.

Bug 2 — isValidPath rejected non-/ paths

// Old code (buggy)
isValidPath(path) {
  if (!path.startsWith('/')) return false;  // ← rejects http://
  ...
}
// → throws 'Invalid template path format: http://...'

Fix: add an early branch for absolute URLs:

if (/^https?:\/\//i.test(path)) {
  return this.isValidUrl(path) &&
    this.config.security.allowedExtensions.some(ext => path.toLowerCase().endsWith(ext));
}

Both fixes are applied to Now/js/TemplateManager.js and Now/dist/now.core.min.js.

Security Notes

Layer What it does
isValidPath Validates URL protocol (http:/https:) + file extension (.html/.htm)
validateRequest Enforces allowedOrigins — default = window.location.origin. Cross-domain URLs are rejected.
processContent Sanitizes loaded HTML via sanitizeElement (configurable via security.sanitize)