Routes With No File Behind Them: Prerendering a Single-Page App Without Cloaking
How a client-routed site whose URLs have no file behind them stays legible to search: an unknown-path fallback that returns the app shell, a prerendered document per advertised route served behind a User-Agent allowlist, why that branch is not cloaking, and a test whose entire job is to assert that something is absent.
📅
Client routing / crawlability
33o1.com is a single-page app: one document answers every path, and a router in the browser decides what to draw. Most of its URLs have no file behind them.
- Shell: one 8,998-byte document answers any unresolved path
- Prerendered: six static documents in the repository, 8,174–32,612 bytes on the wire
- Guard: four dependency-free source tests, one of which exists only to assert strings are absent
Every byte count, hash and command output on this page was re-taken on 2026-09-29, either against the live site with a plain public GET or in a checkout of the repository, and the command that produced it is printed beside it. Re-take them rather than trust them: the numbers here have already moved twice since this page was written, which is exactly why the commands are printed instead of just the results.
What you need
Four things, and only two of them are code.
A server whose fallback you control. The app only survives a reload or a pasted link because the server answers an unknown path with the app shell instead of a 404, and only serves a snapshot to a crawler because something branches on the request. On 33o1 that is nginx: a try_files … /index.html fallback plus a User-Agent branch. That config lives on the host, not in the repository — git ls-files returns no nginx file — so this page cannot show it to you, and I am not going to paste a config I would have had to invent. What the repository does hold is everything the config points at, and that is the half that rots silently.
A way to render your own app outside a browser tab. Each snapshot is the DOM the browser builds at that route, written to a file. On 33o1 the generator drives a real browser and runs on a schedule outside the repository; only its output is committed. Keeping the generator outside the repo is a genuine weakness, not a detail — it shows up further down this page as a snapshot that published one person’s saved progress to crawlers.
Node, and nothing else. The guards are plain CommonJS scripts with no dependency tree to install:
$ node --version
v23.11.1
$ ls package.json node_modules
ls: cannot access 'package.json': No such file or directory
ls: cannot access 'node_modules': No such file or directory
curl. Every live check below is an unauthenticated GET. No key, no account, no header beyond User-Agent.
What you do not need is a prerendering SaaS, a framework with an SSR mode, or a headless-Chrome dependency in your build. What you do need before you start is the list of routes you intend to advertise, because prerendering can only cover routes you can enumerate. That is the real limit of the technique. 33o1 has routes that deliberately get no snapshot and no sitemap entry — this page does not name them, and the guards below are what stop one of them appearing by accident.
Why a route can have no file behind it
A client router reads the path and swaps the matching view module into a container. The server is never asked about /About — the browser was already running when the URL changed. That survives a reload or a pasted link only if unrecognised paths return the shell, not a 404.
The cost is undersold: your 404 is gone. The server returns 200 and HTML for everything, so a mistyped asset path looks like a successful load, and “not a route” becomes the app’s own decision.
What a crawler receives instead
The shell has no content in it; the words arrive when JavaScript runs. Rather than bet on the crawler executing it, each of a small allowlist of named routes gets a prerendered static document holding the text the browser would have built. A crawler asking for such a path receives that file; a browser receives the shell and routes normally.
The size gap is the story, and it is the one claim on this page you should not take on faith. Two commands return it. The first asks for a path that is not a route, so what comes back is the fallback shell:
$ curl -sS -o /dev/null -w '%{size_download}\n' https://33o1.com/NoSuchPathXyz
8998
The second asks for each route while identifying as Googlebot, which is what decides whether you are handed a prerendered document or the shell:
$ GB='Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
$ for u in / /Map /About /Changelog /Privacy /Labyrinth; do
curl -sS -A "$GB" -o /dev/null -w "$u %{size_download}\n" "https://33o1.com$u"
done
/ 13023
/Map 20296
/About 13465
/Changelog 32612
/Privacy 14134
/Labyrinth 8174
So the gap is real but uneven, and on one route it runs the wrong way. /Changelog is 23,614 bytes larger than the 8,998-byte shell, because a changelog accumulates entries and the other routes do not. But /Labyrinth is 824 bytes smaller than the shell. That is not a broken snapshot — the shell carries inline bootstrap script and styling that the rendered document also carries, and that route’s view is short enough that the text it adds does not cover the difference. Take the lesson rather than the number: “the crawler’s copy is bigger” is a symptom, not a test. If you want a test, compare hashes, which is the next section.
Diff the shell against a snapshot and the difference is a <div id="app"> that is empty in one and full in the other. Both ship the same seven <script> tags — grep -c '<script' html/index.html html/prerender/Labyrinth.html returns 7 for each — so nothing is being added or withheld except the view itself.
The live documents are regenerated on a schedule outside the repository, so these exact byte counts drift, which is why this section prints the commands instead of asking you to trust a number. The stamps say so out loud:
$ grep -n 'prerender-time' html/prerender/*.html
html/prerender/About.html:3: <meta name="prerender-time" content="2026-08-05T02:30:07.039Z">
html/prerender/Changelog.html:3: <meta name="prerender-time" content="2026-08-05T02:30:08.214Z">
html/prerender/Labyrinth.html:3: <meta name="prerender-time" content="2026-09-16T23:14:11.159Z">
html/prerender/Map.html:3: <meta name="prerender-time" content="2026-08-05T02:30:04.700Z">
html/prerender/Privacy.html:3: <meta name="prerender-time" content="2026-08-05T02:30:05.873Z">
html/prerender/index.html:3: <meta name="prerender-time" content="2026-08-05T02:30:02.880Z">
Five of the six were written in a single batch 5.3 seconds wide, at 02:30Z on 2026-08-05, and Labyrinth.html carries 23:14Z on 2026-09-16 — off that slot entirely. A seventh snapshot, for the since-retired /Progress route, carried the same 02:30Z slot six weeks after the batch until 33o1 deleted it on 2026-09-28. What the stamps establish is that regeneration is scheduled and roughly when in the day it runs. What they do not establish is the interval, and I am not going to infer one from six samples. Nor do they prove a file is unchanged since its stamp: every byte count on this page fell between the 2026-09-19 and 2026-09-29 measurements, across a site redesign on 2026-09-26, and not one of the six stamps moved. Stamping the artifact with its own generation time costs one <meta> tag and is the cheapest way to answer “is what the crawler got stale?” without instrumenting a generator you may not own.
The files that do it
Everything committed that participates in the crawlable half, with no nginx config and no generator in the list because neither is in the tree:
$ git ls-files | grep -E 'prerender|Router|sitemap|robots|crawl|leak'
html/js/Router.js
html/prerender/About.html
html/prerender/Changelog.html
html/prerender/Labyrinth.html
html/prerender/Map.html
html/prerender/Privacy.html
html/prerender/index.html
html/robots.txt
html/sitemap.xml
test/crawl-surface.test.cjs
test/no-answer-leak.test.cjs
test/prerender-distinct.test.cjs
test/prerender-progress-zero.test.cjs
html/index.html does not match that grep and is the eighth file that matters: it is the shell, the document a browser receives at every virtual route. html/prerender/index.html is its prerendered twin for /, and it is the document every miss falls back to. html/js/Router.js is the router that reads the path and swaps in a view module. html/prerender/<Name>.html is one snapshot per route, and html/sitemap.xml is the list of routes advertised to search engines. The four files under test/ are the guards, and they are the reason this arrangement is maintainable rather than merely clever:
test/crawl-surface.test.cjs— every<loc>in the sitemap must have a snapshot behind it whose ownrel="canonical"agrees, and no editor leftover (.bak,.orig,.save,.old) may sit anywhere under the webroot.test/prerender-distinct.test.cjs— every snapshot must parse, must carry its own title, description and canonical, and must not be a copy of the home page.test/prerender-progress-zero.test.cjs— a snapshot may not ship the renderer’s own saved state.test/no-answer-leak.test.cjs— nothing served may contain a string that is supposed to be earned.
The deploy is rsync -a --delete over html/, which is why three of those four are about the webroot rather than about the build: in this arrangement a file’s presence under html/ is its publication, and nothing else is.
Run the checks yourself
Clone the repository and the whole guard set runs in one line, with no install step:
$ for t in test/crawl-surface.test.cjs test/prerender-distinct.test.cjs \
test/no-answer-leak.test.cjs test/prerender-progress-zero.test.cjs; do
node "$t" >/dev/null && echo "pass $t" || echo "FAIL $t"
done
pass test/crawl-surface.test.cjs
pass test/prerender-distinct.test.cjs
pass test/no-answer-leak.test.cjs
pass test/prerender-progress-zero.test.cjs
Run one on its own and it prints what it actually checked, which matters for the absence tests discussed at the end of this page:
$ node test/crawl-surface.test.cjs
ok crawl-surface — 5 sitemap <loc>s, each mirrored by a prerender file with a matching canonical; 0 backup files under html/
Five, not six: one of the snapshots exists without being advertised in the sitemap, which is allowed. The reverse — a <loc> with no snapshot behind it — is what turns the gate red, and is the failure described in the next section. The advertised set is one grep away:
$ grep -o '<loc>[^<]*</loc>' html/sitemap.xml
<loc>https://33o1.com/</loc>
<loc>https://33o1.com/Map</loc>
<loc>https://33o1.com/About</loc>
<loc>https://33o1.com/Changelog</loc>
<loc>https://33o1.com/Privacy</loc>
Then check that what is committed is what is served. This is the part worth copying, because it is the only check that ties the repository to the live host, and both halves are plain GETs:
$ md5sum html/index.html
1110844593aa2040884fdc5f136aae2e html/index.html
$ curl -sS https://33o1.com/Map | md5sum
1110844593aa2040884fdc5f136aae2e -
$ md5sum html/prerender/Labyrinth.html
51562e456d75892da9ba7dc673769339 html/prerender/Labyrinth.html
$ curl -sS -A "$GB" https://33o1.com/Labyrinth | md5sum
51562e456d75892da9ba7dc673769339 -
Two exact matches, and they say different things. The first says the shell a browser gets at /Map is byte-for-byte the html/index.html in the repository — the server is not rewriting anything on the way out. The second says the document Googlebot gets at /Labyrinth is byte-for-byte the committed snapshot. Together they mean both sides of the branch are in version control and neither is generated per-request, which is the strongest thing you can say about a setup like this without reading the server config.
The honest caveat: this pair matches today, for these two files. The other five snapshots do not match their committed copies right now, because the live generator has re-run since those copies were committed — /Map is 20,296 bytes on the wire against 13,899 on disk. Matching is therefore not a property of the system, it is a measurement of a moment, and a guard that asserted it would fail on schedule.
One more, because it is the thing most SPA retrofits get wrong. The fallback must not apply to your JavaScript module paths, or a typo in an import returns 200 and HTML and the browser reports a syntax error in what it thought was a script:
$ curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' https://33o1.com/View/Map.js
200 application/javascript
$ curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' https://33o1.com/View/nope-xyz.js
404 text/html
404 on the second line is the correct answer and the router depends on it: Router.viewModuleExists() asks the server whether a view module is there rather than consulting a hard-coded list of route names, and that question only has meaning because the shell fallback is scoped to document requests.
The failure that makes it worthless
A sitemap entry promises a distinct document exists. On 2026-08-05 two routes were listed with no prerendered file behind them; the fallback did its job and handed the crawler the shell, so both URLs returned the homepage body byte for byte — duplicate content produced by the safety net. A guard now pins the other half: every sitemap location must name a real prerendered file whose canonical agrees with it. It reports “5 sitemap <loc>s, each mirrored by a prerender file with a matching canonical”. Those entries were deleted, not prerendered.
Why this is not cloaking, and when it would be
Name the mechanism first, because the rest of this section is worth nothing if I dodge it: the split is a branch on the User-Agent request header. Same URL, same second, same server — change that one header and a different document comes back.
$ curl -sS -A "$GB" -o /dev/null -w '%{size_download}\n' https://33o1.com/About
13465
$ curl -sS -o /dev/null -w '%{size_download}\n' https://33o1.com/About
8998
Two commands, ten seconds, 4,467 bytes apart. Sending a Firefox 120 string returns 8998, and sending no user-agent at all (curl -H 'User-Agent:') returns 8998, so nothing else about the request is doing the deciding.
It is also not a Googlebot special case. A bingbot string returns 13465, and so do DuckDuckBot, Applebot, YandexBot, Baiduspider, Yahoo! Slurp, AhrefsBot, SemrushBot, Twitterbot and facebookexternalhit. The match is case-insensitive and anywhere in the string — GOOGLEBOT, and a Chrome string with googlebot glued on the end, both return 13465 — but it is a list of names, not a “contains the word bot” heuristic: SomeRandomBot/1.0 and a bare bot both get the 8,998-byte shell. So do GPTBot, ClaudeBot and Bytespider, which is a decision about AI training crawlers rather than anything to do with search.
The probe that settles it is one loop, and anybody can run it. Ask for every route twice — once as Googlebot, once as a normal client — and hash both bodies:
$ for u in / /Map /About /Changelog /Privacy /Labyrinth /NoSuchPathXyz; do
bot=$(curl -sS -A "$GB" "https://33o1.com$u" | md5sum | cut -c1-12)
eye=$(curl -sS "https://33o1.com$u" | md5sum | cut -c1-12)
printf '%-15s bot=%s browser=%s\n' "$u" "$bot" "$eye"
done
/ bot=2cf48eba927a browser=1110844593aa
/Map bot=40444049e12b browser=1110844593aa
/About bot=3210f5653988 browser=1110844593aa
/Changelog bot=2c167e4b9984 browser=1110844593aa
/Privacy bot=4073ba02f30d browser=1110844593aa
/Labyrinth bot=51562e456d75 browser=1110844593aa
/NoSuchPathXyz bot=2cf48eba927a browser=1110844593aa
Read the right-hand column first: one hash, seven times. Every path returns the identical shell to a browser, which is the whole point of the fallback and is also why a browser-side check of “did the right page load?” tells you nothing at the HTTP layer. The left column is six distinct documents plus one repeat, and the repeat is the flaw — /NoSuchPathXyz hashes the same as /. Hold that; it is the subject of the last part of this section.
That loop is the probe worth stealing. It takes ten seconds, it needs no access to the server, and it answers the only question that matters at the HTTP layer: for each URL, what are the two bodies, and can a reader get both? Run it against your own site before and after you add a crawler branch.
So: yes, this branches on user agent. That is not what makes something cloaking. Cloaking is showing a crawler content the visitor does not get, in order to win a ranking. The header is only the switch; what sits on either side of it is what decides whether the switch is honest, and that is the two tests this build has to pass.
Direction: where the responses differ, the crawler gets less. Correspondence: each prerendered file is a snapshot of what the browser renders at that URL, so nothing exists for the crawler that a visitor cannot reach.
Correspondence survives naming the mechanism, and it survives partly because the branch is trivial to flip. The curl above claimed to be Googlebot from an address that is plainly not Google’s and the server took its word for it — there is no reverse-DNS check. That reads like a hole and is closer to the opposite: cloaking only pays when the visitor cannot see the crawler’s copy, and here anyone can see it with one flag in ten seconds. There is no second, hidden document to be caught with.
Direction is where re-measuring cost me a claim, twice. This section used to say that a crawler asking for an unadvertised path gets the same shell a browser does. That was false on 2026-08-19, false on 2026-09-19, and it is still false on 2026-09-29: in the hash loop above, /NoSuchPathXyz returns 2cf48eba927a to a crawler and 1110844593aa to a browser, and 2cf48eba927a is the home page’s own crawler document. 13,023 bytes, HTTP 200, byte-identical to what / serves a crawler, under a URL that is not /.
So on paths that are not routes the crawler gets more, not less: the duplicate-homepage failure described two sections up, arriving through the crawler branch this time instead of the sitemap. What limits the damage is inside the document — the rel="canonical" in that snapshot reads https://33o1.com/, so the copy disclaims the URL that was asked for. That is a mitigation, not a pass, and the honest summary is that this build satisfies Correspondence and currently fails Direction on every path that is not a route. Whether the browser’s router also lands on the homepage there, I have not measured, so I will not claim the two correspond — what the router does now is the next section.
The test is not technical: would a reader who saw only the crawler’s copy feel sold something the page does not contain? If yes it is cloaking, however implemented. Do not branch on user agent to improve rankings — that is the harmful version of this shape, and not what the split is for here.
Giving the human back a 404 without giving up the fallback
The fallback costs you the 404, and you cannot buy it back at the HTTP layer without breaking the thing the fallback exists for. What you can fix is the paint. Before Router.renderNotFound() existed, a mistyped address quietly painted the home page, home <title> and all, so a wrong URL looked like a working one. Now the router sets a title and description of its own and draws a short screen naming the address that failed:
static renderNotFound(path) {
Head.setTitle("Page not found - 33o1");
The status code is deliberately unchanged — still 200, because the server cannot tell a mistyped path from a route it is supposed to hand to the app. The comment in html/js/Router.js says so in as many words: “This fixes what the HUMAN sees; the status code is deliberately unchanged.”
Two details in that function are worth copying. The failed path is written with textContent, never innerHTML, because it comes straight from the address bar and is therefore attacker-controlled — a not-found screen that echoes the URL is a textbook reflected-XSS sink. And the screen says out loud that saved progress lives in the browser, not in the address, because the most likely reader of a 404 on an app like this is someone who thinks they have just lost something.
The whole of it is served from one file, and you can check that the file you are reading is the file being served:
$ curl -sS https://33o1.com/js/Router.js | md5sum
9ca5384b0cd84a1af02c6befe3333c64 -
$ md5sum html/js/Router.js
9ca5384b0cd84a1af02c6befe3333c64 html/js/Router.js
The honest limit: this does nothing for a crawler, which never runs the router. A crawler asking for a non-route still gets the home page’s snapshot with a canonical pointing elsewhere, as measured above. Two different audiences, two different bugs, and only one of them is fixed.
The snapshot that shipped one browser’s saved state
The failure mode nobody warns you about: if your snapshots are produced by rendering the app in a real browser, they capture that browser’s state, and localStorage is state.
33o1 showed a progress overlay on two routes at the time: /Map and a /Progress twin that has since been deleted. On 2026-09-17 both snapshots, html/prerender/Map.html and the twin’s, had been rendered in a browser that had solved one puzzle, so both said 1/26 and > Network: 4% MAPPED. A brand-new visitor’s first paint told them they had already solved something, and then JavaScript booted and corrected it to zero — a visible flash of wrong state — while the crawler, which never boots the JavaScript, kept the wrong number for good. “4% MAPPED” is a fact about one person’s browser, published as if it were a fact about the site.
The guard that now pins it reads every snapshot carrying the overlay and asserts the count is zero, the fill bar is 0% wide and the network line reads 0% MAPPED:
$ node test/prerender-progress-zero.test.cjs | head -1
ok prerender-progress-zero — 12 checks passed (1 of 6 prerendered pages carry a progress overlay, total derived as 26)
The denominator is derived from the snapshot and then cross-checked against html/js/puzzleStructure.js, never typed into the test. That matters twice: adding a puzzle cannot turn the guard red for the wrong reason, and a snapshot whose denominator has drifted from the live one — showing a crawler a different fraction than a visitor sees — fails instead of passing quietly.
The repository is clean. The live site is not, yet:
$ curl -sS -A "$GB" https://33o1.com/Map | grep -o '>[0-9]*/26<'
>1/26<
That is what “the generator runs outside the repository” costs you: the fix is committed and the guard is green, and the document crawlers are being served is still the old one, until the next scheduled run overwrites it. A guard on the source tree is not a guard on the live host, and if you take one idea from this section, take that one.
A test whose whole job is to prove nothing is there
This site withholds answers, so one guard exists purely to assert an absence. It derives the forbidden set from the project at runtime rather than hard-coding it, then reads every other served file and asserts none contains one. What matters is the two assertions before the scan: the forbidden set must be non-empty, and so must the file list. Absence tests fail by passing, so this one prints both counts on success (7 checks passed (… vs 24 files)).
What it caught: presence in the deployed directory is publication. Build scripts left inside the tree that gets mirrored to the server were served as plain source to anyone guessing a filename. Directory listing was off and the robots file did not mention them; neither is access control. Moving them out fixed it — ignoring them in version control would not, because the deploy mirrors and deletes.
When not to build it this way
If you are choosing now, render on the server or generate pages statically instead. Prerendering behind a fallback is a retrofit for an app that already exists, and it buys crawlability at the price of a second copy of every page — one that drifts unless something checks.