Skip to content

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 covers application/xhtml+xml and application/xml, sends SVG as an attachment, and every GET of those four types carries Content-Security-Policy: default-src 'none'; sandbox; CSS, JS, JSON, WASM, web manifest, PNG and plain text carried neither that nor nosniff.
  • 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 answers 404 {"error":"requested path is invalid"}, and Storage through the same domain is still text/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 400 InvalidKey), no clean URLs, no 404 page (a missing object is HTTP 400 with "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 /about with a 308 and 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.
GoalPathWhat you get
A site at / on your domainStatic host (Pages, Netlify, Workers static assets) for the HTML; Supabase for data, Auth and large filesthe normal split; assets on Storage keep their types and cache at the CDN
Supabase only, any URL acceptableCustom domain add-on + an Edge Function that serves the buildrenders 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 hostnamenonetext/plain on Storage and on Functions
Supabase as file origin, own frontWorker (or any proxy) in front of a public bucketworks at /; the proxy sets the content-type and owns routing
Visitor's browser<ref>.supabase.coStorage or /functions/v1/text/plain, source showncustom domain/functions/v1/<slug>/text/html, renderscustom domain/storage/v1/...text/plainWorker on own hostname/text/html at /fetches files
  1. Project hostname, Storage or Edge Function: text/plain, the browser shows source.
  2. Custom domain, Edge Function path: text/html, the site renders, under /functions/v1/<slug>/.
  3. Custom domain, Storage path: still text/plain.
  4. A Worker on your own hostname in front of either origin: renders at /.

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 typeStorage public URLEdge Function GET
text/html; charset=utf-8text/plaintext/plain
application/xhtml+xmltext/plaintext/plain
application/xmltext/plaintext/plain
image/svg+xmlimage/svg+xml, Content-Disposition: attachmentimage/svg+xml, Content-Disposition: attachment
CSS, JS, JSON, WASM, web manifest, PNG, plain textunchangedunchanged

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.

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 behaviourStorage
/ serves index.htmlbucket 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.htmlHTTP 400, body "statusCode":"404"
/about (clean URL)HTTP 400, body "statusCode":"404"
custom 404 page, SPA fallbacknone; a miss is HTTP 400 with a JSON body
cache headersmax-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 deploynone; 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.

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 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 domainAnswer
/ 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.

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/aboutMissing path
Worker -> public bucket, no custom domainrendered, island hydrated308 to /about/404 "text/html; charset=utf-8" (the site’s 404 page)
Worker -> function via the custom domainrendered, island hydrated308 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.

SituationPracticeEvidenceModule
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 siteServe 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 requirementUse 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 requirementKeep 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-upRetry 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 StorageVersion 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 uploadsDo 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
Doc claimRuntime measuredSeverity
Storage: “For security, HTML files are returned as plain text”1also application/xhtml+xml and application/xml; SVG kept its type with Content-Disposition: attachmentdocs understate the scope
Functions: GET returning text/html is rewritten to text/plain without a custom domain2measured, plus XHTML and XML; POST and HEAD keep text/htmldocs understate the scope
Functions: HTML served on custom domains2measured, under /functions/v1/<slug>/ onlyconsistent

None of these has been filed upstream.

ClaimHow it was checkedModule
Storage rewrites HTML, XHTML, XML; SVG as attachment; CSP sandbox on the rewritten types13 fixtures uploaded with declared types, fetched with no key from the project hostname, the Storage hostname and a signed URLHS01
No index document, no clean URLs, 400 for a miss, 47085 ms overwrite visibilityGETs on the bucket root, a directory, an extensionless path and a missing path; overwrite polled every 2 sHS02
Function rewrites the same types; case and spacing variants, POST, HEADthe same fixtures served by a deployed function, with the handler setting the typeHS03
Custom domain bring-up and the activate 400add-on, initialize, DNS through the Cloudflare API, reverify and activate polledHS04; the 400 is in the RUNLOG only
The Astro build in a browser on each path, against a local controlheadless Chromium: document type, rendered heading, island hydration, font, About linkHS05
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 curlHS05; site slug in the RUNLOG only
A Worker in front gives /, 308 and a real 404the same browser check against two Worker hostnamesHS06
Teardown left nothing behindHS07: 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 onlyHS07

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.

ModuleExperimentTestArtifact
HS01static-hostinghs01-storage-content-types.tsout/2026-10-06
HS02static-hostinghs02-storage-site-mechanics.tsout/2026-10-06
HS03static-hostinghs03-function-file-server.tsout/2026-10-06
HS04static-hostinghs04-custom-domain-up.tsout/2026-10-06
HS05static-hostinghs05-astro-in-browser.tsout/2026-10-06
HS06static-hostinghs06-worker-front.tsout/2026-10-06
HS07static-hostinghs07-custom-domain-down.tsout/2026-10-06
RUNLOGstatic-hostingRUNLOG.md - the activate 400 and the site slug probenone published
  1. Supabase, “Storage Quickstart,” Supabase Docs. https://supabase.com/docs/guides/storage/quickstart ↩ ↩2

  2. Supabase, “Limits,” Supabase Docs. https://supabase.com/docs/guides/functions/limits ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  3. Supabase, “Custom Domains,” Supabase Docs. https://supabase.com/docs/guides/platform/custom-domains ↩ ↩2 ↩3

  4. Supabase, “Manage Custom Domain usage,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/custom-domains ↩