Now.js Framework Documentation
SPA in a Subfolder — Complete Setup Guide
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.html → admin/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 escapepaths.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.htmlWhy 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) |
Related Documentation
- RouterManager — routing,
base,.htaccessexamples - TemplateManager — path resolution,
isValidPath, directives - MenuManager — 2-level nesting limit