URLs and redirects
Telling kiss where the site is deployed and how its host serves URLs, keeping old addresses alive with redirect files, and letting the host cache hashed files for a year.
Host URL policy
Two of the things kiss emits are decided by your host, not by the generator. Both are config keys, and both default to what kiss has always done, so an existing site does not move.
Where the site is deployed: host
host says it once: 'netlify', 'cloudflare', 'firebase', 'vercel', 'github-pages', or a list of them for a site deployed to more than one. It is null by default, because kiss does not guess. Each host sets the host file for every concern that has one: Redirects and Cache headers. A concern's own format still wins, so a site that keeps its headers in netlify.toml says so:
new Kiss({ host: 'netlify', cacheHeaders: { format: 'none' } })
// _redirects from the aliases; no _headers: netlify.toml stays in charge
host | redirects.format it sets | cacheHeaders.format it sets |
|---|---|---|
'netlify' | 'netlify' (_redirects) | 'netlify' (_headers) |
'cloudflare' | 'netlify' (_redirects) | 'cloudflare' (_headers) |
'firebase' | 'firebase' | 'firebase' |
'vercel' | 'vercel' | none yet: the build warns, and cache-headers.json has the list |
'github-pages' | 'none' | 'none': GitHub Pages cannot be configured (it sends max-age=600 on everything) |
A name kiss does not know throws at new Kiss(), with every known one listed. host never sets links.trailingSlash: Firebase's slash policy is in your firebase.json, not in the host's name, so that stays its own statement (below).
The trailing slash on a directory index
links: { trailingSlash: true } by default. Measured live on 2026-09-16 — not inferred:
| Host | /courses/ (a directory index) | /about (a file page) |
|---|---|---|
Netlify — verified on www.a1k9training.co.uk | 200; bare /courses 301s here | 200; /about/ 301s here |
Firebase Hosting with cleanUrls: true + trailingSlash: false — verified on learna.ac.uk | 301 → /courses; the bare form is 200 | 200; /about/ 301s here |
| GitHub Pages — file pages measured on a live deployment (relayed to this branch by a clean-room session, not measured here) | unverified for a real directory index | 200; /about.html is also 200, /about/ 404s, and nothing redirects |
| Cloudflare Pages, nginx | unverified — each has a trailing-slash mode; measure your own deployment | unverified |
The two verified hosts agree on file pages and contradict each other on directory indexes, so no single default can be right for both. true matches Netlify and is what kiss has always emitted; false matches that Firebase configuration:
new Kiss({ siteUrl: 'https://example.com', links: { trailingSlash: false } })
// {{canonical}} on courses/index.html → https://example.com/courses
// its <loc>, its llms.txt entry, its feed <link> and {{link "courses/index"}} → the same
The setting moves every URL kiss derives for a page, together — {{canonical}}, {{link}}, <loc>, llms.txt, the feed and an alias's target. That is the point: a page advertised at /courses but linked as /courses/ ships a redirect hop on every internal click. It deliberately does not move:
{{isActive}}— page identity is not a host policy, and a nav highlight must not depend on where you deploy./about,/about/andabout/index.htmlstay one key either way.- a trailing slash you wrote —
{{absUrl '/courses/'}}is stillhttps://example.com/courses/. - file extensions, and the site root, which is
https://example.com/under both.
One consequence worth knowing: on Firebase with cleanUrls + trailingSlash: false, about.html and about/index.html are both served at /about — so extensionLess stops being a URL decision there and becomes purely an output-path one. On Netlify the two shapes stay distinct.
If you are not sure what your host does, measure it rather than guess: deploy once, then curl -sI https://yoursite/some-section/ and curl -sI https://yoursite/some-section and see which returns 200 and which returns 301.
The redirect file format
See Redirects below. _redirects is a Netlify and Cloudflare Pages file; Firebase and Vercel read their own, so kiss writes no host file until you name one. redirects: { format: … } says which, and redirects.json carries the rules as data whatever you pick.
Redirects
A page's aliases are the old URL paths it now answers. There is no method to call; an alias is a property of a page, not a file you ask for.
An alias is a fact about your site — "this page used to answer /old" — and that fact is portable. Every alias is a permanent redirect: redirects.json records status: 301 on each rule so a consumer has the code in the data rather than hardcoding it, but there is no way to ask for a 302, a 308 or a 410, and no forced or wildcard rules. The field marks the place such a thing would live, not a setting. The file it goes into is one host's encoding of it, and hosts disagree. So every settled build with an alias writes redirects.json — the host-neutral list — always, and then whatever host encodings config.redirects.format names beside it.
format is unset by default, and takes a list. kiss does not guess where you deploy: with no format you get the IR and no host file. Because every version before this one always wrote _redirects, a build that has aliases and no format logs one warning telling you what to set — an explicit 'none' or [] is a decision and stays silent.
new Kiss({ redirects: { format: ['netlify', 'firebase'] } })
// writes redirects.json, _redirects AND redirects.firebase.json
A list because a site can legitimately deploy to more than one host — Netlify previews and Firebase production is a real shape, and choosing one at build time would mean building twice. A bare string or a single function is a list of one. Duplicates collapse, order is kept, and a typo anywhere in the list throws at new Kiss() rather than being quietly dropped.
redirects.format | Writes | For |
|---|---|---|
'netlify' | _redirects | Netlify (opens in a new tab), Cloudflare Pages (opens in a new tab) |
'firebase' | redirects.firebase.json — a fragment to merge | Firebase Hosting, which ignores _redirects entirely |
'vercel' | redirects.vercel.json — a fragment to merge | Vercel |
'htaccess' | redirects.htaccess — a fragment to Include | Apache |
'none' / [] | nothing but the IR | a site that owns its own redirects |
| unset (the default) | nothing but the IR, plus one notice | a site that has not said where it deploys |
| a function | whatever it returns | anything else — nginx, Apache, a CDN API |
The Firebase, Vercel and Apache formats emit a fragment, not a firebase.json, a vercel.json or a .htaccess. Your real config file holds hosting targets, headers, rewrites and often years of hand-maintained redirect history; kiss will not rewrite it. Merge the fragment on your own terms — for Apache, by Include-ing redirects.htaccess or concatenating it. (That fragment uses mod_alias Redirect, not RewriteRule: a rewrite's left-hand side is a regex, so an alias containing a . would match more paths than the one it names.)
A custom writer is (rules, { buildDir, config }) => [{ file, contents }] — or one entry, or nothing at all. It does not have to start from scratch: every built-in encoder is a named export, so a writer can compose one rather than reimplement it.
import Kiss, { renderRedirects, renderFirebaseRedirects } from 'kiss-ssg'
new Kiss({
redirects: {
format: (rules) => [
// Netlify's exact bytes, somewhere else, plus a rule of your own
{
file: 'edge/_redirects',
contents:
renderRedirects(rules) + '/vendor/* https://cdn.example/:splat 200\n',
},
{
file: 'hosting/redirects.json',
contents: renderFirebaseRedirects(rules),
},
],
},
})
renderRedirects, renderRedirectsJson, renderFirebaseRedirects, renderVercelRedirects and renderHtaccessRedirects are all (rules) => string over the same list your writer is handed. Paths are relative to the build folder and one that escapes it is refused. A writer that throws fails the build, exactly as a failed write does: a redirect writer that dies quietly would be the silent no-op this whole block exists to end. An unknown format name throws at new Kiss() rather than writing nothing.
new Kiss({
redirects: {
format: (rules) => ({
file: 'redirects.conf',
contents: rules
.map((r) => `rewrite ^${r.from}$ ${r.to} permanent;`)
.join('\n'),
}),
},
})
You do not need a writer to do this, though: report().redirects.rules is the same [{ from, to }] list, so a deploy script can read kiss.report() after .complete() and write any format it likes without kiss knowing the host exists.
kiss
.page({ view: 'about.hbs', aliases: ['/about-us', '/team.html'] })
.pages({
view: 'blog/post.hbs',
model: 'posts', // a renamed post's own JSON carries `"aliases": ["/news/2024/thing.html"]`
path: 'blog',
})
.generate()
writes public/_redirects (and public/redirects.json beside it):
/about-us /about 301
/news/2024/thing.html /blog/thing 301
/team.html /about 301
The target is the page's canonical path — /, /courses/, /about — the same string that page's own {{canonical}} renders and its <loc> in sitemap.xml carries, so a redirect can never point at a URL your site does not serve. Sources are written as you gave them, with a leading / added and any ?query or #fragment dropped; a trailing slash is kept, because /old/ and /old are two different paths to a host and you are the one who knows which was linked. Lines are sorted, so two identical builds write identical bytes and a committed _redirects does not churn.
On a .pages() fan-out the aliases belong to each record, never to the registration: .pages({ aliases: [...] }) is not broadcast over the fan-out, because one source path redirecting to N different pages is not a redirect. Put them in the model item. A page with generate: false contributes none — there would be nothing at the other end.
A site with no aliases writes no file at all, not an empty one and not an empty IR, so a hand-written _redirects you keep in src/assets/ is copied into the build and left alone. An alias is one plain path, and a character kiss does not know to be plain is refused: letters, digits, - . _ ~, a %XX escape, /, $ & ' , ; =, a : inside a segment (/a:b) and any non-ASCII letter (/café) are kept. A space or a line break is dropped, because the file is space-separated columns and one rule per line; so is anything a host reads as pattern syntax (*, {a,b}, [a-z], (x), +, !, @, ? and a segment starting with : such as /blog/:slug), and a bare query or fragment (?utm=1). The build warns, naming the alias and the page, and report().redirects.dropped lists each as { alias, page, reason } with reason one of whitespace, pattern or query-only (kiss-ssg check --summary prints one alias dropped: line for each). And if the file cannot be written the build fails, the way a page that cannot be written fails it — a site published without its redirects is a site whose old URLs 404.
Three findings ride along, all advisory and all in report().redirects ({ file, aliases, removed, collisions, moved, rules, json, formats, files } — formats is the list that ran, file is the first host file, files is the complete and authoritative list, or null when there is nothing to say — no alias anywhere in the site, no removal and no move):
removed— pages the last record wrote that this build does not, minus any an alias now covers: a page that vanished with no redirect. It is the one finding that needs a recorded knowledge base (see Recording the knowledge base); withoutAIKB/last-build.jsonit is empty, and an unreadable one is treated the same way. Paths are compared build-relative on both sides, so a record made from one working directory and a check run from another still agree, and are reported as the URL a browser asked for (/old.html,/old/) — the same string to put inaliases; the canonical spelling (/old) covers too.moved— a page the last record and this build both have, paired by itsidrather than by its path, whose output path changed and whose old path no alias covers:{ id, from, to }per page, sorted byfrom. It needs a record too, and anidon both sides, so it follows a page whose identity is stable — a.page()page whosepath,slugorextmoved, or any page (a fan-out record included) carrying an explicitid— and not one whose identity moved with it: a.pages()item's default id embeds its slug (blog/post/<slug>), so renaming a post's slug changes the id as well and the event reads as aremovedinstead. Underids: 'path'every default id is the page's path, so it moves with the page: there,movedfollows explicit ids only. Ids pair only when the last record used the sameidssetting, so switching it reports no false moves. A move suppresses the matchingremoved, so one rename is one finding.collisions— aliases a live page already answers, by its canonical path (/about) or by the file itself (/about.html), and any source two pages both claim. On both hosts a non-forced rule whose source is a real file is silently ignored — the line does nothing at all, with no error anywhere — which is why this is worth saying out loud.
Under --summary they print as removed without redirect: <path>, moved without redirect: <from> -> <to> (<id>) and alias collides with a page: <path>; the build log's notice for a move carries the fix with it (add "<from>" to that page's aliases). None of them changes ok, and none changes the exit code.
Cache headers
A file kiss content-hashed (assets: { hash: true }, and the consent client kiss-consent.<hash>.js) has its bytes in its name, so a browser can keep it for a year and never be wrong. kiss writes the host file that says so, Cache-Control: public, max-age=31536000, immutable, for those files only:
- Never an unhashed file. A photograph replaced under its old name would stay stale in returning visitors' browsers for as long as the header said. Give an image or font that changes now and then a day (
public, max-age=86400, stale-while-revalidate=604800) in your own config. - Never HTML. A page answers at several URLs (
/about,/about.html,/courses/index.html), so a rule for one misses the others. Netlify already serves HTML withpublic, max-age=0, must-revalidate(measured on five sites, 2026-11-04).
cacheHeaders.format picks the file, and host sets it when you leave it unset:
cacheHeaders.format | Writes | Notes |
|---|---|---|
'netlify' / 'cloudflare' | _headers: each hashed file by exact path | Rebuilt on every deploy, so the paths never go stale. A _headers you keep in src/assets/ keeps every line; kiss appends its marked block after it |
'firebase' | cache-headers.firebase.json: a fragment to merge | Patterns, not paths (regex for .<8 hex>.css and .js, and the consent client), so you merge it into firebase.json once and it stays right |
'none' / [] | nothing but the list | a site that sets its own headers (netlify.toml) |
unset, no host | nothing but the list, and the audit's host-unset | |
| a function | whatever it returns | (rules, { buildDir, config }) => [{ file, contents }]; renderHeadersBlock, renderFirebaseCacheHeaders and renderCacheHeadersJson are named exports |
cache-headers.json ({ version: 1, rules: [{ path, headers }] }) carries the list whatever you pick, and only when the build hashed something. Nothing is written in dev, nor by a failed build. report().cacheHeaders is { rules, files, formats, unset, merged }, or null when nothing was written.
On Firebase, a file kiss did not hash but whose name looks hashed (vendor/lib.deadbeef.js) matches the pattern too and would be cached for a year: the build names it in a warning, so you can rename it.
Not yet measured, so not stated here: which wins on Netlify when _headers and netlify.toml both set Cache-Control for one path, and how Firebase orders a regex header rule against your own source globs. Check your deploy with curl -sI on one hashed file until this section says.
Security headers
securityHeaders: true adds a short set of security headers to the same host files as the cache headers, in a marked block of its own after theirs. It is off by default: a header changes what a browser allows on a site that already works.
new Kiss({ host: 'netlify', securityHeaders: true })
It writes only the headers a site sets once and never touches again, with the values in OWASP's HTTP Headers Cheat Sheet:
| Header | Value |
|---|---|
Strict-Transport-Security | max-age=63072000 |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | geolocation=(), camera=(), microphone=() |
- No
includeSubDomainsorpreloadon HSTS. Both are slow to undo and safe only once every subdomain serves HTTPS (amail.orold.subdomain on plain HTTP would be unreachable for two years); add them in your own_headerswhen that is true. - No Content-Security-Policy, framing header (
X-Frame-Options,frame-ancestors) orCross-Origin-Opener-Policy. Each protects more and costs more: a CSP blocks every widget you add later until the policy changes, denying framing stops Moodle showing a learning package, and COOP can break pop-up payment and sign-in windows. Add one in your own_headerswhen the site has a reason; thekiss-securityskill says how. - Check for location, camera and microphone first.
Permissions-Policyturns them off for the page and every frame in it, so a map's "my location" button or a video-call embed stops working, on the live site only (the dev server sends no headers). If the site uses one, set your ownPermissions-Policy, such asgeolocation=(self): in_headers, and kiss then leaves that header to you; on Firebase, infirebase.jsonafter you merge kiss's fragment, which always carries all four. - Never
X-XSS-Protection: deprecated, and it can open holes in a site that is otherwise safe. - Your own headers win. A header your
src/assets/_headersalready sets, for any path, is left out of kiss's_headersblock, and the build says which it left to you. The Firebase fragment always carries all four, since Firebase never reads_headers.
host picks the file: _headers for 'netlify' and 'cloudflare'; security-headers.firebase.json, a fragment to merge into firebase.json, for 'firebase' (Firebase replaces HSTS on its own *.web.app subdomains, so only a custom domain serves it). 'vercel' has no file yet and the build warns; 'github-pages' cannot set headers. security-headers.json ({ version: 1, rules: [{ path: '/*', headers }] }) carries the list whatever the host. Nothing is written in dev, nor by a failed build. Turning it off with cleanBuild: false, where the build folder is kept, leaves an earlier build's block and files in it, still live: the build warns and names them, and you delete them. kiss never deletes them itself, because several Kiss instances can share one folder and another one's headers look the same. The cache headers warn the same way when nothing is hashed.
