The site router Last updated: 20. Oct 2026
One script that runs before every request to a site. It lets the request through, serves another path, redirects or answers it itself — and can set cookies, headers and the session on the way. Use it for moved addresses, maintenance mode, API tokens and your own sign-in.
Set up the router
Name the script in the router section of site.json, or in its own file #/router.json. Like the other sections it is inherited from the app, and the site's own value wins.
"router": { "script": "Router.js" } // = #/Services/Router.js
The value is a path inside #/Services/: a file name, or folders and a file name separated by / — "Routing/Router.js" means #/Services/Routing/Router.js. Each part uses letters, digits, ., _ and - and cannot start with .; the file ends in .js; at most 100 characters. The path always stays inside #/Services/, and nothing under #/ is ever served.
// #/Services/Router.js
export default (route) => {
if (request.url.startsWith('/old-news/'))
return route.redirect(request.url.replace('/old-news/', '/news/'), 301);
return route.next();
}; It runs for every request on the site's addresses — pages, files, images, uploads, API calls and addresses that do not exist. The first parameter is the route object with the results below; the name is yours, (r) => … works as well.
Inside the router you have what an API script has: request (including request.Jwt), query, docly.getHeader, docly.getCookie, docly.getRemoteIp and the network functions. form is not: the request body belongs to the page or API script that answers, and reading it in the router would leave that script with an empty body.
Results
The router ends by returning exactly one result:
Result | What happens |
|---|---|
| The request continues as normal: the address is looked up and served. |
| The site serves |
| Sends the browser to |
| Answers with |
| Answers with plain text ( |
| Answers with HTML ( |
| Answers with any other content type, such as |
The answer functions build the response in the router; rewrite serves a page from the site. Use json, text, html or send for a finished answer — a health check, an error an API client can read, maintenance mode or a whole small API. Use rewrite for a page with the site's template and layout, such as a sign-in page or a branded 404.
The answer functions take any status except 3xx — those belong to redirect — and 204 sends no body. charset=utf-8 is added to text types that lack it. The answer is no-store unless the router sets Cache-Control itself with docly.setHeader, carries X-Content-Type-Options: nosniff, and gets the site's own headers (the headers setting, such as the Content-Security-Policy) like every other response.
return route.json({ result: 'ok' }); // 200, JSON
return route.json({ error: 'invalid_token' }, 401);
return route.text('pong');
return route.html('<h1>Back soon</h1>', 503);
return route.send(xml, 200, 'application/xml');
return route.send(null, 404); // Docly's standard answer
return route.rewrite('/login?returnUrl=' + encodeURIComponent(request.url), { status: 401 }); // the site's own page Cookies, headers and the session
The router can set cookies with docly.setCookie, headers with docly.setHeader and the session with docly.writeJwt and deleteJwt. They are sent with every result — also with a redirect or an answer such as route.json — so a sign-in can set a state cookie and send the browser to the provider in one step. A page or API script that runs after next or rewrite can set more; for the same header name the page wins, because it sets last.
The status and the body come only from the result. docly.setResultCode, docly.denyAccess, docly.setContentDisposition and the download functions are not available in the router — and neither are docly.getUploads and docly.getUploadBase64, since the request body belongs to the page or API script that answers.
The JWT is the identity. Write it with docly.writeJwt when a sign-in completes, and read request.Jwt in the router and in API scripts. A machine call with a Bearer token has no session: the router checks the token, and an API script that needs to know the caller reads the header itself.
When the router fails
An exception, more than 10 seconds, a missing return or a value that is not a route result gives 500 with a generic body — never the page. Cookies and headers the router set are discarded, so a failed sign-in leaves no half-written session. A forgotten return in front of a result is a 500, not an open door.
An invalid script path, a missing file or a router setting that cannot be read — an invalid #/router.json, an invalid site.json that mentions "router", or "router" written as text instead of an object — answers every request on the site with 500 as well. A router that is set never falls back to serving the site without it.
The site console shows each error once per version of the router — not once per request, since the router runs for every image and file — and again when the script or the setting changes.
Keep it fast. The router runs for every request; call external services only where they are needed, such as a sign-in callback.
Sign-in, publications and caching
Docly sign-in belongs to the publication. On a publication that requires sign-in, Docly signs the user in and checks access first; the router runs after that, for signed-in users only. It can redirect, rewrite, answer and set cookies there, but it never weakens the platform's protection.
Your own sign-in needs a publication without sign-in — otherwise Docly's sign-in answers before the router runs. The router is then the protection for every address on the site, uploaded files, scaled images and
#/Rootpages included. Start from deny: name what is public, and require the session for the rest.Pages do not get
request.Jwt. They are rendered once and shared — see Hash files are cached. Per-user data comes from an API script.The router runs before Docly's page cache, so a cached page is never served without the router's decision. Responses from a site with a router are never marked
publicfor CDNs and proxies, because the router decides per request, and a response where the router set a cookie isno-store.
Example: API tokens
Machine calls to /API/ must carry a valid Bearer token; the rest of the site is open. Only the SHA-256 of each token is stored on the site — the token itself lives with the caller.
// #/Services/Router.js — machine calls to /API/ need a token, the rest of the site is open.
export default (route) => {
if (!request.url.startsWith('/API/')) return route.next();
const h = docly.getHeader('Authorization') || '';
const hash = h.startsWith('Bearer ') ? docly.sha256(h.substring(7).trim()) : null;
const clients = docly.getJson('#/Services/api-clients.json') || []; // only SHA-256 hashes are stored
if (hash && clients.some(c => c.Sha256 === hash && c.Active !== false)) return route.next();
docly.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"');
return route.json({ error: 'invalid_token' }, 401);
}; Example: your own sign-in (OAuth)
The publication has no sign-in, and the router is the protection. The sign-in is complete when the router has written the JWT; from then on request.Jwt decides.
// #/Services/Router.js — the publication has NO sign-in. Provider settings in #/Services/oauth.json.
const PUBLIC = ['/assets/', '/about'];
export default (route) => {
const url = request.url;
const cfg = docly.getJson('#/Services/oauth.json');
// 1. Start: remember a state value and send the browser to the provider.
if (url.startsWith('/auth/login')) {
const state = guid();
docly.setCookie('oauth_state', state, { maxAge: 600 });
docly.setCookie('oauth_return', query.returnUrl || '/', { maxAge: 600 });
return route.redirect(cfg.authorizeUrl
+ '?response_type=code&client_id=' + encodeURIComponent(cfg.clientId)
+ '&redirect_uri=' + encodeURIComponent(cfg.redirectUri)
+ '&state=' + state);
}
// 2. Callback: check the state, exchange the code, write the session.
if (url.startsWith('/auth/callback')) {
if (!query.state || query.state !== docly.getCookie('oauth_state'))
return route.text('The sign-in has expired. Please try again.', 400);
const token = docly.httpFormPost(cfg.tokenUrl, {
grant_type: 'authorization_code', code: query.code, redirect_uri: cfg.redirectUri,
client_id: cfg.clientId, client_secret: cfg.clientSecret
});
if (token.DoclyError || !token.access_token) return route.text('The sign-in failed.', 502);
const user = docly.httpGet(cfg.userInfoUrl, { Authorization: 'Bearer ' + token.access_token });
docly.writeJwt({ sub: user.sub, name: user.name });
docly.setCookie('oauth_state', '', { maxAge: 0 });
// Follow the return address only when it stays on the site.
const back = docly.getCookie('oauth_return') || '/';
return route.redirect(back.startsWith('/') && !back.startsWith('//') ? back : '/');
}
// 3. The public parts of the site.
if (PUBLIC.some(p => url.startsWith(p))) return route.next();
// 4. Everything else needs the session.
if (request.Jwt) return route.next();
if (url.startsWith('/API/')) return route.send(null, 401);
return route.redirect('/auth/login?returnUrl=' + encodeURIComponent(url));
};