From 16a54322b017cb0303a552aa32d59cc1b898467a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 30 Oct 2026 12:43:47 +0000 Subject: [PATCH 1/7] Say index names the homepage and committed pages win over the SPA fallback Folders always serve their own index.html, a custom index names only the homepage, and clean URLs stay on behind a 200 fallback, so a page a framework built statically answers its URL ahead of the client-side shell. --- content/(reference)/config-file.mdx | 8 ++++---- content/(serve)/routing.mdx | 12 +++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index cbc05dc4..df2e4b49 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -96,14 +96,14 @@ While `name` is set in the file, the API refuses to rename the Space. The file w | Key | Type | Default | What it does | | --- | --- | --- | --- | -| `index` | string or `false` | inferred, see below | The directory index file. `false` disables index resolution | +| `index` | string or `false` | inferred, see below | The homepage file `/` serves. Folders always serve their own `index.html`. `false` serves no homepage | | `fallback` | string, or `{ path, status }` | none | The committed file served when no exact route matches. `status` is 100 to 599 | -| `cleanUrls` | boolean | `true`, unless a 200-status `fallback` is set | Serves `about.html` at `/about` | +| `cleanUrls` | boolean | `true` | Serves `about.html` at `/about`, ahead of any fallback | | `listing` | boolean | on when no index could be inferred and the version runs no worker | Automatic directory listings | -`index` defaults to `index.html` when the version has one at the root. Otherwise, if the version publishes exactly one HTML file, that file. Otherwise, if there is exactly one HTML file at the root, that one. Otherwise `false`. +`index` defaults to `index.html` when the version has one at the root. Otherwise, if a 200-status `fallback` names a committed file and the version runs no worker, that file. Otherwise, if the version publishes exactly one HTML file, that file. Otherwise, if there is exactly one HTML file at the root, that one. Otherwise `false`. -`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it also turns `cleanUrls` and `listing` off unless you set them yourself. A `fallback` path that is not a committed file fails the publish. +`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs; set `"cleanUrls": false` to send extensionless routes to the shell instead. A `fallback` path that is not a committed file fails the publish. ## Metadata and appearance diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx index 3e2344cc..b431d99b 100644 --- a/content/(serve)/routing.mdx +++ b/content/(serve)/routing.mdx @@ -218,9 +218,9 @@ Those compiled 308s accept every method and carry the incoming query across. ## Index files and clean URLs -`index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, and it keeps its literal `/docs/index.html` URL too. +`index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, `/docs` redirects there with a 308, and the file keeps its literal `/docs/index.html` URL too. A folder always serves its own `index.html`, the file every static site generator and framework writes for a folder. -With no config, Spacefast infers the index: a root `index.html`, else a single public HTML file, else the one root-level HTML file, else none, which puts the Space in files mode with directory listings. +With no config, Spacefast infers the homepage: a root `index.html`, else the file a 200 `fallback` names, else a single public HTML file, else the one root-level HTML file, else none, which puts the Space in files mode with directory listings. Two keys in `sf.jsonc` change it: @@ -231,9 +231,9 @@ Two keys in `sf.jsonc` change it: } ``` -`index` names the directory index file, defaulting to `index.html`. Setting it to `false` disables index resolution. +`index` names the homepage, the file `/` serves. It doesn't rename folder indexes: with `"index": "home.html"`, `docs/home.html` doesn't answer `/docs/`, and the publish warns with `custom_index_not_folder_index`. Setting it to `false` serves no homepage. -`cleanUrls` serves a flat `about.html` at `/about` as well as `/about.html`, and redirects `/about/` to `/about` with a 308. It defaults to on, unless a 200-status SPA fallback is configured, in which case it defaults to off. +`cleanUrls` serves a flat `about.html` at `/about` as well as `/about.html`, and redirects `/about/` to `/about` with a 308. It defaults to on, with or without a fallback, so a committed page always wins its URL over the SPA shell. In a config that declares `"version": 1`, these live under `serve` as `serve.index`, `serve.cleanUrls`, `serve.spaFallback`, and `serve.directoryListing`. @@ -257,7 +257,9 @@ Point `fallback` at a committed file to serve it whenever no exact route matches The long form takes a status: `{"path": "/index.html", "status": 200}`. Only `200` and `404` are accepted, and anything else resolves to 200. A bare string means 200. The path has to be a committed, non-private file, otherwise publish fails with `config_invalid`. -`{"path": "404.html", "status": 404}` is a custom error page, not an SPA fallback. A 200-status fallback is the SPA one, and it turns clean URLs off by default and forces directory listings off. +`{"path": "404.html", "status": 404}` is a custom error page, not an SPA fallback. A 200-status fallback is the SPA one. It turns directory listings off by default, and without a root `index.html` its file is also the homepage. + +Committed files win over the fallback, as on Netlify, Vercel, and Cloudflare Pages. A framework that builds some routes as static pages and leaves the rest to a client-side shell needs no config beyond the fallback: `about.html` answers `/about`, `docs/index.html` answers `/docs/`, and every other route gets the shell. Set `"cleanUrls": false` to send extensionless routes to the shell instead. The fallback is skipped for around 110 asset extensions, including `css`, `js`, `json`, `png`, `svg`, `woff2`, `pdf` and `zip`, so a missing asset returns 404 instead of your HTML shell. It is a denylist on purpose, which keeps dotted client routes such as `/users/jane.doe` and `/v1.2.3` reaching the shell. From 5634ce3a827bbe1b20541faf56b043d405e6bb9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 30 Oct 2026 14:04:20 +0000 Subject: [PATCH 2/7] Document zero-config SPA fallback inference and fallback false Publish infers the SPA shell from a root 200.html, _shell.html, or an index.html beside a JavaScript bundle with no root 404.html, and "fallback": false turns that off. --- content/(reference)/config-file.mdx | 4 ++-- content/(serve)/routing.mdx | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index df2e4b49..6688f0bd 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -97,13 +97,13 @@ While `name` is set in the file, the API refuses to rename the Space. The file w | Key | Type | Default | What it does | | --- | --- | --- | --- | | `index` | string or `false` | inferred, see below | The homepage file `/` serves. Folders always serve their own `index.html`. `false` serves no homepage | -| `fallback` | string, or `{ path, status }` | none | The committed file served when no exact route matches. `status` is 100 to 599 | +| `fallback` | string, `false`, or `{ path, status }` | inferred for a single-page app, see below | The committed file served when no exact route matches. `status` is 100 to 599. `false` turns the inference off | | `cleanUrls` | boolean | `true` | Serves `about.html` at `/about`, ahead of any fallback | | `listing` | boolean | on when no index could be inferred and the version runs no worker | Automatic directory listings | `index` defaults to `index.html` when the version has one at the root. Otherwise, if a 200-status `fallback` names a committed file and the version runs no worker, that file. Otherwise, if the version publishes exactly one HTML file, that file. Otherwise, if there is exactly one HTML file at the root, that one. Otherwise `false`. -`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs; set `"cleanUrls": false` to send extensionless routes to the shell instead. A `fallback` path that is not a committed file fails the publish. +`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs; set `"cleanUrls": false` to send extensionless routes to the shell instead. A `fallback` path that is not a committed file fails the publish. Left unset, `fallback` is inferred: a root `200.html`, else `_shell.html`, else a root `index.html` beside a JavaScript bundle when there's no root `404.html`. See [SPA fallback](/routing#spa-fallback). ## Metadata and appearance diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx index b431d99b..55917500 100644 --- a/content/(serve)/routing.mdx +++ b/content/(serve)/routing.mdx @@ -247,7 +247,15 @@ Without a `404.html`, Spacefast renders its own page: status 404, the heading "P ## SPA fallback -Point `fallback` at a committed file to serve it whenever no exact route matches: +A single-page app needs no config. When `fallback` isn't set and the version runs no worker, publish picks the shell from the files and says so with the note `spa_fallback_inferred`: + +1. A root `200.html`, the name Surge, SvelteKit, and Nuxt use. +2. Otherwise `_shell.html`, which TanStack Start writes. +3. Otherwise a root `index.html` next to a JavaScript bundle (`assets/`, `static/js/`, `_app/`, `_next/static/`, `_nuxt/`, or `asset-manifest.json`), as long as there is no root `404.html`. + +A root `404.html` says routes can miss, so a static export that ships one keeps its real 404s. Files mode (`"listing": true` or `"index": false`) is never an SPA. Set `"fallback": false` to turn the inference off. + +Point `fallback` at a committed file to choose the shell yourself: ```jsonc sf.jsonc { @@ -255,7 +263,7 @@ Point `fallback` at a committed file to serve it whenever no exact route matches } ``` -The long form takes a status: `{"path": "/index.html", "status": 200}`. Only `200` and `404` are accepted, and anything else resolves to 200. A bare string means 200. The path has to be a committed, non-private file, otherwise publish fails with `config_invalid`. +The long form takes a status: `{"path": "/index.html", "status": 200}`. `false` serves no fallback, so a miss gets the nearest `404.html`. Only `200` and `404` are accepted, and anything else resolves to 200. A bare string means 200. The path has to be a committed, non-private file, otherwise publish fails with `config_invalid`. `{"path": "404.html", "status": 404}` is a custom error page, not an SPA fallback. A 200-status fallback is the SPA one. It turns directory listings off by default, and without a root `index.html` its file is also the homepage. From d7eb151556e54c29683f6d0572c4d863ff6a487a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 30 Oct 2026 14:28:38 +0000 Subject: [PATCH 3/7] Say which bundler output makes a build an SPA --- content/(reference)/config-file.mdx | 2 +- content/(serve)/routing.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index 6688f0bd..3640f6c5 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -103,7 +103,7 @@ While `name` is set in the file, the API refuses to rename the Space. The file w `index` defaults to `index.html` when the version has one at the root. Otherwise, if a 200-status `fallback` names a committed file and the version runs no worker, that file. Otherwise, if the version publishes exactly one HTML file, that file. Otherwise, if there is exactly one HTML file at the root, that one. Otherwise `false`. -`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs; set `"cleanUrls": false` to send extensionless routes to the shell instead. A `fallback` path that is not a committed file fails the publish. Left unset, `fallback` is inferred: a root `200.html`, else `_shell.html`, else a root `index.html` beside a JavaScript bundle when there's no root `404.html`. See [SPA fallback](/routing#spa-fallback). +`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs; set `"cleanUrls": false` to send extensionless routes to the shell instead. A `fallback` path that is not a committed file fails the publish. Left unset, `fallback` is inferred: a root `200.html`, else `_shell.html`, else a root `index.html` beside a bundler's output when there's no root `404.html`. See [SPA fallback](/routing#spa-fallback). ## Metadata and appearance diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx index 55917500..c41c3c15 100644 --- a/content/(serve)/routing.mdx +++ b/content/(serve)/routing.mdx @@ -251,7 +251,7 @@ A single-page app needs no config. When `fallback` isn't set and the version run 1. A root `200.html`, the name Surge, SvelteKit, and Nuxt use. 2. Otherwise `_shell.html`, which TanStack Start writes. -3. Otherwise a root `index.html` next to a JavaScript bundle (`assets/`, `static/js/`, `_app/`, `_next/static/`, `_nuxt/`, or `asset-manifest.json`), as long as there is no root `404.html`. +3. Otherwise a root `index.html` next to a bundler's output, as long as there is no root `404.html`. That means `_app/`, `_next/static/`, `_nuxt/`, `asset-manifest.json`, or a script with a content hash in its name directly under `assets/` or under `static/js/`, such as `assets/index-DiwrgTda.js`. A hand-written `assets/app.js` doesn't count. A root `404.html` says routes can miss, so a static export that ships one keeps its real 404s. Files mode (`"listing": true` or `"index": false`) is never an SPA. Set `"fallback": false` to turn the inference off. From 45406393433f2157ffd5c83fa45f7e4f2c2b3c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 30 Oct 2026 14:29:07 +0000 Subject: [PATCH 4/7] Use plain wording the prose check accepts --- content/(reference)/config-file.mdx | 2 +- content/(serve)/routing.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index 3640f6c5..efeb8cbe 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -103,7 +103,7 @@ While `name` is set in the file, the API refuses to rename the Space. The file w `index` defaults to `index.html` when the version has one at the root. Otherwise, if a 200-status `fallback` names a committed file and the version runs no worker, that file. Otherwise, if the version publishes exactly one HTML file, that file. Otherwise, if there is exactly one HTML file at the root, that one. Otherwise `false`. -`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs; set `"cleanUrls": false` to send extensionless routes to the shell instead. A `fallback` path that is not a committed file fails the publish. Left unset, `fallback` is inferred: a root `200.html`, else `_shell.html`, else a root `index.html` beside a bundler's output when there's no root `404.html`. See [SPA fallback](/routing#spa-fallback). +`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs; set `"cleanUrls": false` to send extensionless routes to the shell instead. A `fallback` path that is not a committed file fails the publish. Left unset, `fallback` is inferred: a root `200.html`, else `_shell.html`, else a root `index.html` beside a build tool's output when there's no root `404.html`. See [SPA fallback](/routing#spa-fallback). ## Metadata and appearance diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx index c41c3c15..add9f86f 100644 --- a/content/(serve)/routing.mdx +++ b/content/(serve)/routing.mdx @@ -251,7 +251,7 @@ A single-page app needs no config. When `fallback` isn't set and the version run 1. A root `200.html`, the name Surge, SvelteKit, and Nuxt use. 2. Otherwise `_shell.html`, which TanStack Start writes. -3. Otherwise a root `index.html` next to a bundler's output, as long as there is no root `404.html`. That means `_app/`, `_next/static/`, `_nuxt/`, `asset-manifest.json`, or a script with a content hash in its name directly under `assets/` or under `static/js/`, such as `assets/index-DiwrgTda.js`. A hand-written `assets/app.js` doesn't count. +3. Otherwise a root `index.html` next to a build tool's output, as long as there is no root `404.html`. That means `_app/`, `_next/static/`, `_nuxt/`, `asset-manifest.json`, or a script with a content hash in its name directly under `assets/` or under `static/js/`, such as `assets/index-DiwrgTda.js`. A hand-written `assets/app.js` doesn't count. A root `404.html` says routes can miss, so a static export that ships one keeps its real 404s. Files mode (`"listing": true` or `"index": false`) is never an SPA. Set `"fallback": false` to turn the inference off. From 2f8e2cd59ef3556916fa81ce692ae6cb6faf0a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 30 Oct 2026 16:52:11 +0000 Subject: [PATCH 5/7] Say each page has one URL with clean URLs on /about.html and /docs/index.html now redirect to the URL the page is served at, query included, and /docs/index redirects to /docs/. --- content/(publish)/recipes/html.mdx | 2 +- content/(serve)/routing.mdx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/content/(publish)/recipes/html.mdx b/content/(publish)/recipes/html.mdx index 0dc825c0..9e365b60 100644 --- a/content/(publish)/recipes/html.mdx +++ b/content/(publish)/recipes/html.mdx @@ -41,7 +41,7 @@ Published: https://quiet-harbor.space.fast Files 4 ``` -`about.html` answers `/about` as well as `/about.html`, because clean URLs are on by default. See [Redirects, rewrites, and headers](/routing) for that and for the `_redirects` and `_headers` files, which publish alongside your pages. +`about.html` answers `/about`, and `/about.html` redirects there, because clean URLs are on by default. See [Redirects, rewrites, and headers](/routing) for that and for the `_redirects` and `_headers` files, which publish alongside your pages. ## The gotcha diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx index add9f86f..65c41ea2 100644 --- a/content/(serve)/routing.mdx +++ b/content/(serve)/routing.mdx @@ -209,16 +209,16 @@ There is no generic trailing-slash normalizer. The behavior comes from what the | Situation | Behavior | | --- | --- | -| `dir/index.html` exists | `/dir/` serves, `/dir` answers 308 to `/dir/` | +| `dir/index.html` exists | `/dir/` serves; `/dir`, `/dir/index` and `/dir/index.html` answer 308 to `/dir/` | | A directory listing exists for `dir` | `/dir/` serves, `/dir` answers 308 to `/dir/` | -| Clean URLs are on and `page.html` exists | `/page` serves, `/page/` answers 308 to `/page` | +| Clean URLs are on and `page.html` exists | `/page` serves; `/page/` and `/page.html` answer 308 to `/page` | | Anything else | No normalization. The other spelling falls through to the SPA fallback or a 404 | Those compiled 308s accept every method and carry the incoming query across. ## Index files and clean URLs -`index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, `/docs` redirects there with a 308, and the file keeps its literal `/docs/index.html` URL too. A folder always serves its own `index.html`, the file every static site generator and framework writes for a folder. +`index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, and `/docs`, `/docs/index` and `/docs/index.html` redirect there with a 308. With clean URLs on, each page has one URL, as on Cloudflare Pages. A folder always serves its own `index.html`, the file every static site generator and framework writes for a folder. With no config, Spacefast infers the homepage: a root `index.html`, else the file a 200 `fallback` names, else a single public HTML file, else the one root-level HTML file, else none, which puts the Space in files mode with directory listings. @@ -233,7 +233,7 @@ Two keys in `sf.jsonc` change it: `index` names the homepage, the file `/` serves. It doesn't rename folder indexes: with `"index": "home.html"`, `docs/home.html` doesn't answer `/docs/`, and the publish warns with `custom_index_not_folder_index`. Setting it to `false` serves no homepage. -`cleanUrls` serves a flat `about.html` at `/about` as well as `/about.html`, and redirects `/about/` to `/about` with a 308. It defaults to on, with or without a fallback, so a committed page always wins its URL over the SPA shell. +`cleanUrls` serves a flat `about.html` at `/about`, and redirects `/about.html` and `/about/` there with a 308, query string included. A rewrite that lands on `/about.html` still serves the file, and a page you address with your own redirect or `_headers` rule keeps its `.html` URL. With `"cleanUrls": false`, pages answer only at their file URL. It defaults to on, with or without a fallback, so a committed page always wins its URL over the SPA shell. In a config that declares `"version": 1`, these live under `serve` as `serve.index`, `serve.cleanUrls`, `serve.spaFallback`, and `serve.directoryListing`. From 99add9eb2a284a0844e67852251ddaa57438421a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 30 Oct 2026 16:53:32 +0000 Subject: [PATCH 6/7] Scope the fallback-homepage rule and the cleanUrls opt-out precisely The fallback file is the homepage only when index is unset, there's no root index.html, and no worker runs. cleanUrls false stops flat pages answering extensionless URLs; folders still serve index.html. --- content/(reference)/config-file.mdx | 2 +- content/(serve)/routing.mdx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index efeb8cbe..721cf7df 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -103,7 +103,7 @@ While `name` is set in the file, the API refuses to rename the Space. The file w `index` defaults to `index.html` when the version has one at the root. Otherwise, if a 200-status `fallback` names a committed file and the version runs no worker, that file. Otherwise, if the version publishes exactly one HTML file, that file. Otherwise, if there is exactly one HTML file at the root, that one. Otherwise `false`. -`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs; set `"cleanUrls": false` to send extensionless routes to the shell instead. A `fallback` path that is not a committed file fails the publish. Left unset, `fallback` is inferred: a root `200.html`, else `_shell.html`, else a root `index.html` beside a build tool's output when there's no root `404.html`. See [SPA fallback](/routing#spa-fallback). +`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs. With `"cleanUrls": false`, a flat `about.html` no longer answers `/about`, so that route reaches the shell. A `fallback` path that is not a committed file fails the publish. Left unset, `fallback` is inferred: a root `200.html`, else `_shell.html`, else a root `index.html` beside a build tool's output when there's no root `404.html`. See [SPA fallback](/routing#spa-fallback). ## Metadata and appearance diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx index 65c41ea2..93ed1d49 100644 --- a/content/(serve)/routing.mdx +++ b/content/(serve)/routing.mdx @@ -220,7 +220,7 @@ Those compiled 308s accept every method and carry the incoming query across. `index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, and `/docs`, `/docs/index` and `/docs/index.html` redirect there with a 308. With clean URLs on, each page has one URL, as on Cloudflare Pages. A folder always serves its own `index.html`, the file every static site generator and framework writes for a folder. -With no config, Spacefast infers the homepage: a root `index.html`, else the file a 200 `fallback` names, else a single public HTML file, else the one root-level HTML file, else none, which puts the Space in files mode with directory listings. +With no `index` set, Spacefast infers the homepage: a root `index.html`, else the file a 200 `fallback` names (unless the version runs a worker, which keeps `/`), else a single public HTML file, else the one root-level HTML file, else none, which puts the Space in files mode with directory listings. Two keys in `sf.jsonc` change it: @@ -265,9 +265,9 @@ Point `fallback` at a committed file to choose the shell yourself: The long form takes a status: `{"path": "/index.html", "status": 200}`. `false` serves no fallback, so a miss gets the nearest `404.html`. Only `200` and `404` are accepted, and anything else resolves to 200. A bare string means 200. The path has to be a committed, non-private file, otherwise publish fails with `config_invalid`. -`{"path": "404.html", "status": 404}` is a custom error page, not an SPA fallback. A 200-status fallback is the SPA one. It turns directory listings off by default, and without a root `index.html` its file is also the homepage. +`{"path": "404.html", "status": 404}` is a custom error page, not an SPA fallback. A 200-status fallback is the SPA one. It turns directory listings off by default. When `index` isn't set, there's no root `index.html` and the version runs no worker, its file is also the homepage. -Committed files win over the fallback, as on Netlify, Vercel, and Cloudflare Pages. A framework that builds some routes as static pages and leaves the rest to a client-side shell needs no config beyond the fallback: `about.html` answers `/about`, `docs/index.html` answers `/docs/`, and every other route gets the shell. Set `"cleanUrls": false` to send extensionless routes to the shell instead. +Committed files win over the fallback, as on Netlify, Vercel, and Cloudflare Pages. A framework that builds some routes as static pages and leaves the rest to a client-side shell needs no config beyond the fallback: `about.html` answers `/about`, `docs/index.html` answers `/docs/`, and every other route gets the shell. Set `"cleanUrls": false` and a flat `about.html` no longer answers `/about`, so that route reaches the shell; folders still serve their `index.html`. The fallback is skipped for around 110 asset extensions, including `css`, `js`, `json`, `png`, `svg`, `woff2`, `pdf` and `zip`, so a missing asset returns 404 instead of your HTML shell. It is a denylist on purpose, which keeps dotted client routes such as `/users/jane.doe` and `/v1.2.3` reaching the shell. From bc288ac9903e6a7161fd7dba8b6886e692aba5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sat, 31 Oct 2026 00:23:28 +0000 Subject: [PATCH 7/7] Clarify clean URL behavior for flat pages and folder indexes --- content/(serve)/routing.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx index 93ed1d49..8a6d847d 100644 --- a/content/(serve)/routing.mdx +++ b/content/(serve)/routing.mdx @@ -209,7 +209,7 @@ There is no generic trailing-slash normalizer. The behavior comes from what the | Situation | Behavior | | --- | --- | -| `dir/index.html` exists | `/dir/` serves; `/dir`, `/dir/index` and `/dir/index.html` answer 308 to `/dir/` | +| `dir/index.html` exists | `/dir/` serves and `/dir` answers 308 to `/dir/`. With clean URLs on, `/dir/index` and `/dir/index.html` also redirect there | | A directory listing exists for `dir` | `/dir/` serves, `/dir` answers 308 to `/dir/` | | Clean URLs are on and `page.html` exists | `/page` serves; `/page/` and `/page.html` answer 308 to `/page` | | Anything else | No normalization. The other spelling falls through to the SPA fallback or a 404 | @@ -218,7 +218,7 @@ Those compiled 308s accept every method and carry the incoming query across. ## Index files and clean URLs -`index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, and `/docs`, `/docs/index` and `/docs/index.html` redirect there with a 308. With clean URLs on, each page has one URL, as on Cloudflare Pages. A folder always serves its own `index.html`, the file every static site generator and framework writes for a folder. +`index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, and `/docs` redirects there with a 308. With clean URLs on, `/docs/index` and `/docs/index.html` also redirect there. A folder always serves its own `index.html`, the file every static site generator and framework writes for a folder. With no `index` set, Spacefast infers the homepage: a root `index.html`, else the file a 200 `fallback` names (unless the version runs a worker, which keeps `/`), else a single public HTML file, else the one root-level HTML file, else none, which puts the Space in files mode with directory listings. @@ -233,7 +233,7 @@ Two keys in `sf.jsonc` change it: `index` names the homepage, the file `/` serves. It doesn't rename folder indexes: with `"index": "home.html"`, `docs/home.html` doesn't answer `/docs/`, and the publish warns with `custom_index_not_folder_index`. Setting it to `false` serves no homepage. -`cleanUrls` serves a flat `about.html` at `/about`, and redirects `/about.html` and `/about/` there with a 308, query string included. A rewrite that lands on `/about.html` still serves the file, and a page you address with your own redirect or `_headers` rule keeps its `.html` URL. With `"cleanUrls": false`, pages answer only at their file URL. It defaults to on, with or without a fallback, so a committed page always wins its URL over the SPA shell. +`cleanUrls` serves a flat `about.html` at `/about`, and redirects `/about.html` and `/about/` there with a 308, query string included. A rewrite that lands on `/about.html` still serves the file, and a page you address with your own redirect or `_headers` rule keeps its `.html` URL. With `"cleanUrls": false`, flat pages answer only at their file URL. Folders still serve their `index.html` at the folder URL: `/docs/` serves and `/docs` redirects there. The literal `/docs/index.html` also serves, while `/docs/index` has no generated alias. `cleanUrls` defaults to on, with or without a fallback, so a committed page always wins its URL over the SPA shell. In a config that declares `"version": 1`, these live under `serve` as `serve.index`, `serve.cleanUrls`, `serve.spaFallback`, and `serve.directoryListing`.