Static site hosting on Supabase
Whether a Supabase project can host a static frontend on its own, with nothing
else in front of it, for anyone deciding where an app’s HTML lives. The project
hostnames refuse to serve HTML, and the one documented exception puts the site
under /functions/v1/ with no way to move it to /.
Every figure marked measured comes from the
supabase-lab harness,
experiments/static-hosting,
run on 2026-10-06 against one Micro project in ap-southeast-1, created and
destroyed the same day. The site is a real Astro 7 build with Tailwind, a
self-hosted font, an SVG asset and one React island that calls the project’s
Auth API with the anon key. Each copy was loaded in headless Chromium with no
API key on the page request, as a visitor would load it (the island’s own Auth
call carries the anon key), next to a control: the same build on a
plain local static server, which rendered, hydrated and followed its nav link.
One run per row. Anything not marked measured is cited to the docs and was not
run.
TL;DR:
- Neither Storage nor an Edge Function serves HTML on the project
hostnames. A public bucket and an Edge Function both delivered the Astro build as
text/plain, so the browser showed the source. The docs say so for both.12 The rewrite also coversapplication/xhtml+xmlandapplication/xml, sends SVG as an attachment, and every GET of those four types carriesContent-Security-Policy: default-src 'none'; sandbox; CSS, JS, JSON, WASM, web manifest, PNG and plain text carried neither that nornosniff. - The custom domain add-on renders a function’s HTML, and only under
/functions/v1/<slug>/. The same build rendered through the custom domain. The domain root answers404 {"error":"requested path is invalid"}, and Storage through the same domain is stilltext/plain. The add-on costs $10 per month and becomes the project’s Auth domain.34 - Storage has none of a static host’s routing. No index document (the
bucket root answers HTTP
400InvalidKey), no clean URLs, no 404 page (a missing object is HTTP400with"statusCode":"404"in a JSON body), and an overwrite reached the public URL after 47085 ms, one object at a time. - A clean
/needs something in front of Supabase. A Cloudflare Worker on an own hostname served/, answered/aboutwith a308and a missing path with the site’s 404 page, in front of Storage with no custom domain at all. At that point Supabase is holding files.
Which do I pick
Section titled “Which do I pick”| Goal | Path | What you get |
|---|---|---|
A site at / on your domain | Static host (Pages, Netlify, Workers static assets) for the HTML; Supabase for data, Auth and large files | the normal split; assets on Storage keep their types and cache at the CDN |
| Supabase only, any URL acceptable | Custom domain add-on + an Edge Function that serves the build | renders under https://<domain>/functions/v1/<slug>/; $10 per month; 5 MB bundle (API deploy) or 20 MB (CLI local bundling); every page view is an invocation (reasoned, not measured) |
| Supabase only, on the project hostname | none | text/plain on Storage and on Functions |
| Supabase as file origin, own front | Worker (or any proxy) in front of a public bucket | works at /; the proxy sets the content-type and owns routing |
- Project hostname, Storage or Edge Function:
text/plain, the browser shows source. - Custom domain, Edge Function path:
text/html, the site renders, under/functions/v1/<slug>/. - Custom domain, Storage path: still
text/plain. - A Worker on your own hostname in front of either origin: renders at
/.
What the project hostnames rewrite
Section titled “What the project hostnames rewrite”The same 13 fixtures (11 content types) were uploaded to a public bucket with their declared content-type, and served from an Edge Function that set the same types itself. Requests carried no API key.
| Declared type | Storage public URL | Edge Function GET |
|---|---|---|
text/html; charset=utf-8 | text/plain | text/plain |
application/xhtml+xml | text/plain | text/plain |
application/xml | text/plain | text/plain |
image/svg+xml | image/svg+xml, Content-Disposition: attachment | image/svg+xml, Content-Disposition: attachment |
| CSS, JS, JSON, WASM, web manifest, PNG, plain text | unchanged | unchanged |
Storage records the declared type and changes it on the way out: the
storage.objects row for index.html reads text/html; charset=utf-8. Every
GET of an HTML, XHTML, XML or SVG file on both paths also carried
Content-Security-Policy: default-src 'none'; sandbox and
X-Content-Type-Options: nosniff, so a document that did render could run no
script; the other types carried neither header. The rewrite held for TEXT/HTML and for
text/html;charset=utf-8 without the space, set by the function handler; on
<ref>.functions.supabase.co and <ref>.storage.supabase.co; and through a
Storage signed URL. A POST to the function kept text/html, as the docs scope
the rewrite to GET.2 A HEAD to the function kept text/html without
the CSP and nosniff headers; a HEAD has no body to render. Neither POST nor HEAD
was sent to Storage.
The reason is not stated next to the rule, and the shape of the defence points one way: a public bucket accepts arbitrary uploads, and serving them as active documents on the project’s own origin would make it a phishing and stored-XSS host (reasoned, not measured). Entity graphs on managed Postgres hit the Storage half of this on its demo UI.
Storage as a site host
Section titled “Storage as a site host”Past the content-type, the features a static host provides without configuration are absent. Measured on the public object URL of the project hostname:
| Static-host behaviour | Storage |
|---|---|
/ serves index.html | bucket root answers HTTP 400, an 87-byte body {"statusCode":"400","error":"InvalidKey","message":"Invalid key: ","code":"InvalidKey"} (recorded as 87 B; full text from the HS05 screenshot) |
/about/ serves about/index.html | HTTP 400, body "statusCode":"404" |
/about (clean URL) | HTTP 400, body "statusCode":"404" |
| custom 404 page, SPA fallback | none; a miss is HTTP 400 with a JSON body |
| cache headers | max-age=3600 on upload served as public, max-age=3600; cf-cache-status HIT on both HS02e GETs (HS01 had already fetched the object) |
| atomic deploy | none; an overwritten app.css reached the public URL after 47085 ms (24 polls at 2 s) |
There is no deploy unit to test: each object is its own write, so during an upload of a new build a visitor can receive old HTML with new assets, or the reverse. Assets a site hosted elsewhere loads from Storage keep their declared types and cache at the CDN, which is the use Storage does fit.
An Edge Function as file server
Section titled “An Edge Function as file server”A function can carry a whole build. The lab inlined the Astro output (28 files,
fonts included, the same count as the Storage copy) as base64 in one source of 538978 bytes and deployed it through the
Management API, which bundles server-side with a 5 MB ceiling; CLI local
bundling allows 20 MB.2 The handler strips the mount prefix, maps
/ and /about/ to index.html and returns the site’s 404.html. On the
project hostname it changes nothing for the visitor: the Astro build arrived as
text/plain.
Two smaller facts from building it. The site has to be built for the mount
path (base: "/functions/v1/<slug>" in Astro), because the function lives
under it and Astro writes absolute asset URLs. And a redeploy replaces the
whole function, which is the closest Supabase gets to an atomic deploy
(reasoned, not measured).
The custom domain exception
Section titled “The custom domain exception”The Edge Functions limits page names custom domains as the one place HTML is served;2 the custom domains page says custom domains “are not intended to enable hosting of frontend applications through Edge Functions”.3 Both are true at runtime.
Through the custom domain the Astro build at /functions/v1/<slug>/ rendered,
hydrated its island, loaded its font, called Auth (HTTP 200) and followed its
About link, served as text/html; charset=utf-8; the CSP
header was not recorded on this path. The same bucket through the same domain stayed text/plain. The documented
exception is for Functions, and Storage behaved accordingly.
The path cannot be shortened below /functions/v1/<slug>/. The custom domain
fronts the project’s API gateway, which routes by service prefix (HS05-domain-paths,
the same answers in two cycles; the error body is from the first pass’s ad hoc
request):
| Path on the custom domain | Answer |
|---|---|
/ and /about/ | 404 {"error":"requested path is invalid"} |
/<slug>/ | 404 |
/functions/<slug>/ | 401 |
/functions/v1/<slug>/ | 200 text/html; charset=utf-8 |
A function named site with a build made for /functions/v1/site served its
pages at /functions/v1/site/ and /functions/v1/site/about/, and its 404 page
with a 404 for a missing path. That is the most production-shaped URL a
Supabase-only site gets. Nothing in the Management API or the custom-domain
docs sets a root route or a rewrite.
Bringing the domain up took one CNAME and two TXT records on the first
attempt (one TXT on each later cycle). The _acme-challenge record is not in
the initialize response and appears on a
later reverify poll, which the medium-serverless experiment had already found.
Verification completed 175 s into the DNS-and-reverify loop that starts after
initialize. activate straight afterwards answered
HTTP 400 and the same call about ten minutes later answered 201; the 400
body was not recorded, so the cause is open (both from the run log of the first
attempt, which was stopped before writing an artifact). Three later cycles the
same day, each on a new project with the same hostname, were asked for the
ownership TXT only, verified after 67 s, 24 s and 66 s, and got 201 on the
first activate. One refusal in four does not settle the cause. The retry HS04
now carries is unit-tested and has not fired live.
With a Worker in front
Section titled “With a Worker in front”A Cloudflare Worker on an own hostname, fetching from the origin and setting the content-type from the file extension, gave the production shape in both arrangements the lab tried:
| Arrangement | / in Chromium | /about | Missing path |
|---|---|---|---|
| Worker -> public bucket, no custom domain | rendered, island hydrated | 308 to /about/ | 404 "text/html; charset=utf-8" (the site’s 404 page) |
| Worker -> function via the custom domain | rendered, island hydrated | 308 to /about/ | 404 "text/html; charset=utf-8" |
The Worker does the content-type and routing work itself, so in front of Storage it needs no custom domain and no function, and the site is served by the Worker. It is recorded as the contrast case: once a proxy is allowed, Supabase is a file origin, and Cloudflare Workers + Supabase: an architecture reference covers the Storage-from-a-Worker path.
What to do about it
Section titled “What to do about it”| Situation | Practice | Evidence | Module |
|---|---|---|---|
A site at / | Host the HTML on a static host; keep Supabase for data, Auth and Storage. | Neither project hostname renders HTML, and the custom domain cannot serve /. | HS05 |
| Large assets for that site | Serve images and downloads from a public bucket with cacheControl set. | CSS, JS, JSON, WASM, web manifest, PNG and plain text kept their types; max-age uploads served as HIT. Fonts were not in the fixture set. | HS01, HS02 |
| Supabase-only requirement | Use the custom domain add-on and a short function slug, built for /functions/v1/<slug>. | Renders at /functions/v1/site/; root and shorter paths answer 404 or 401. | HS05, RUNLOG |
| Supabase-only requirement | Keep the build under 5 MB for API deploys, 20 MB for CLI local bundling. | The documented ceilings; the lab’s Astro build was 538978 bytes inlined. | HS05 |
| Custom domain bring-up | Retry custom-hostname/activate after verification instead of failing on a 400. | Answered 400 right after verification, 201 about ten minutes later; three later first calls answered 201. Cause open. | HS04, RUNLOG |
| Deploying into Storage | Version asset paths and upload HTML last if a bucket backs a proxied site. | Each object is its own write; an overwrite took 47085 ms to reach the public URL. | HS02 |
| Anything that renders user uploads | Do not try to get HTML, XHTML or SVG rendered from a bucket. | The project hostnames rewrite them and add a sandbox CSP to every GET of those types; the custom domain still rewrites Storage. | HS01, HS03 |
Where the docs disagree with runtime
Section titled “Where the docs disagree with runtime”| Doc claim | Runtime measured | Severity |
|---|---|---|
| Storage: “For security, HTML files are returned as plain text”1 | also application/xhtml+xml and application/xml; SVG kept its type with Content-Disposition: attachment | docs understate the scope |
Functions: GET returning text/html is rewritten to text/plain without a custom domain2 | measured, plus XHTML and XML; POST and HEAD keep text/html | docs understate the scope |
| Functions: HTML served on custom domains2 | measured, under /functions/v1/<slug>/ only | consistent |
None of these has been filed upstream.
Evidence, by module
Section titled “Evidence, by module”| Claim | How it was checked | Module |
|---|---|---|
| Storage rewrites HTML, XHTML, XML; SVG as attachment; CSP sandbox on the rewritten types | 13 fixtures uploaded with declared types, fetched with no key from the project hostname, the Storage hostname and a signed URL | HS01 |
| No index document, no clean URLs, 400 for a miss, 47085 ms overwrite visibility | GETs on the bucket root, a directory, an extensionless path and a missing path; overwrite polled every 2 s | HS02 |
| Function rewrites the same types; case and spacing variants, POST, HEAD | the same fixtures served by a deployed function, with the handler setting the type | HS03 |
| Custom domain bring-up and the activate 400 | add-on, initialize, DNS through the Cloudflare API, reverify and activate polled | HS04; the 400 is in the RUNLOG only |
| The Astro build in a browser on each path, against a local control | headless Chromium: document type, rendered heading, island hydration, font, About link | HS05 |
No root path on the custom domain; /functions/v1/site/ | pinned GETs of five paths through the custom domain (HS05-domain-paths); the site slug by ad hoc curl | HS05; site slug in the RUNLOG only |
A Worker in front gives /, 308 and a real 404 | the same browser check against two Worker hostnames | HS06 |
| Teardown left nothing behind | HS07: custom hostname DELETE 200, 3 DNS records removed and 0 left on re-read, add-on DELETE 200. The Workers (deleted by hand first, wrangler delete exit 1) and the project (make destroy, GET 404) are in the RUNLOG only | HS07 |
Not run: a Free-plan project, browsers other than Chromium, CLI local bundling
with static_files for the site (the Edge Function
limits page covers that deploy
path), and the activate 400’s response body.
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| HS01 | static-hosting | hs01-storage-content-types.ts | out/2026-10-06 |
| HS02 | static-hosting | hs02-storage-site-mechanics.ts | out/2026-10-06 |
| HS03 | static-hosting | hs03-function-file-server.ts | out/2026-10-06 |
| HS04 | static-hosting | hs04-custom-domain-up.ts | out/2026-10-06 |
| HS05 | static-hosting | hs05-astro-in-browser.ts | out/2026-10-06 |
| HS06 | static-hosting | hs06-worker-front.ts | out/2026-10-06 |
| HS07 | static-hosting | hs07-custom-domain-down.ts | out/2026-10-06 |
| RUNLOG | static-hosting | RUNLOG.md - the activate 400 and the site slug probe | none published |
Related docs
Section titled “Related docs”- Edge Function limits, one ceiling at a time - the bundle ceilings by deploy path and the GET-only HTML rewrite this page extends.
- Cloudflare Workers + Supabase: an architecture reference - the Worker-in-front arrangement, with Storage reached from a Worker.
- Entity graphs on managed Postgres - a demo UI that met the Storage rewrite and moved to Workers static assets.
- Locking down Supabase - what a custom domain does and does not gate on the data surface.
References
Section titled “References”-
Supabase, “Storage Quickstart,” Supabase Docs. https://supabase.com/docs/guides/storage/quickstart ↩ ↩2
-
Supabase, “Limits,” Supabase Docs. https://supabase.com/docs/guides/functions/limits ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Supabase, “Custom Domains,” Supabase Docs. https://supabase.com/docs/guides/platform/custom-domains ↩ ↩2 ↩3
-
Supabase, “Manage Custom Domain usage,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/custom-domains ↩