Static hosts serve real files. That works for a report with index.html and a few assets. It breaks for a client-side router: the user refreshes on /dashboard or shares /item/42, and the host looks for those paths on disk. Without a matching file you get a 404, even though the app would have rendered the route in the browser.
deed.page fixes that with an opt-in SPA flag. Turn it on and unknown paths fall back to root index.html. Leave it off and the host keeps exact-file serving, including an optional root 404.html.
What SPA mode changes
Default serving (no SPA):
/servesindex.html/abouttriesabout,about.html, thenabout/index.html- A miss can use root
404.htmlif you uploaded one; otherwise plain404 - A folder without
index.htmlcan show a directory listing
With SPA mode on (X-Spa: 1 / spa=true):
- Real files still win first (
/assets/app.jsstays a JS file) - After the usual HTML path lookups fail, the host serves root
index.htmlinstead of404.htmlor a listing miss - Your front-end router then reads
location.pathnameand draws the right view
SPA mode is stored on the site metadata (spa: true in publish and get_site responses). Send it on the publish that should own the flag.
REST: X-Spa or ?spa=1
Publish a tiny app with a client route. JSON map (handy in agents that already hold strings):
curl -sS -X POST https://deed.page/v1/sites \
-H 'content-type: application/json' \
-H 'X-Spa: 1' \
-d '{
"slug": "spa-demo",
"files": {
"index.html": "<!doctype html><html><body><div id=\"app\"></div><script>const r=location.pathname;document.getElementById(\"app\").textContent=\"route: \"+r;</script></body></html>",
"assets/app.js": "/* real files still served as files */"
}
}'Equivalent query form: ?slug=spa-demo&spa=1. A gzip tarball works the same way — add -H "X-Spa: 1" to the PUT:
tar -C ./dist -czf /tmp/app.tar.gz .
curl -sS -T /tmp/app.tar.gz \
"https://deed.page/v1/sites?slug=spa-demo" \
-H "Content-Type: application/gzip" \
-H "X-Spa: 1" \
-H "Authorization: Bearer $DEEDPAGE_TOKEN"The helper script in the repo accepts --spa and sets that header for you.
On first anonymous publish, store the one-time deed_tok_… token. Later updates need Authorization: Bearer. The response includes spa: true when the flag is set.
After publish, both of these should return HTML from index.html (not a plain 404):
https://spa-demo.deed.page/https://spa-demo.deed.page/dashboard(or/s/spa-demo/dashboardon the path fallback)
MCP: spa on publish_site
The Streamable HTTP MCP server at https://deed.page/mcp exposes the same flag as a boolean on publish_site and update_site:
{
"name": "publish_site",
"arguments": {
"slug": "spa-demo",
"spa": true,
"files": {
"index.html": "<!doctype html><h1>spa</h1><script>/* router */</script>"
}
}
}Omit spa (or send false) for exact-file hosting. Pair with merge: true when you are adding chunks under the 4 MB per-request body cap; SPA is independent of merge and of opt-in ttl_seconds.
When to use it — and when not to
Use SPA mode for Vite/React/Vue/Svelte builds that rely on History API routes, canvas demos with deep links, or any static export where “missing path” should mean “load the app shell.”
Skip SPA mode for multi-page static sites, docs trees, or anything that should return a real 404 (or your custom 404.html) for unknown URLs. SPA fallback hides those misses by design.
Still upload real assets. CSS, JS, images, and fonts must be real paths in the publish. SPA only changes the HTML fallback for paths that are not files.
Keep the token. SPA does not make the slug world-writable. Strangers still get 409 slug_taken. Only the Bearer token updates or deletes the site.
Related
- Publish without a human claim step: /blog/publish-without-a-human
- MCP walkthrough: /blog/mcp-publish-walkthrough
- Large sites with merge: /blog/merge-publish-large-sites
- Opt-in TTLs: /blog/ttl-for-throwaway-pages
- Docs: /docs
- OpenAPI: /openapi.json
- Skill for agents: /skill.md
Questions or corrections: ops@avatar33.com.