Hosting a static site with its API on the same origin
A build, a dist/ folder, and the question that follows: where does it go, and how do its pages talk to the backend? On Zerolith the site and its functions share one origin. Deploys, SPA fallback, real 404s, versions, and the price to the cent.
An app-generation tool (Lovable, Bolt, v0, Claude, Cursor) usually leaves you with two things: a
dist/ folder after npm run build, and a question. Where do you put it, and how do its pages
talk to the backend? The usual answer takes three services: a static host for the frontend,
another one for the API, and a CORS configuration between the two that always ends up breaking at
the wrong moment.
Zerolith now serves static sites. What changes is that your functions answer on the same origin
as the site, under /api/. Here is how it works, what it costs, and what the service does not do.
What you publish
A site is the output of the build, not the sources: the folder that Vite, Astro, Next with
static export or a plain text editor produces, with an index.html at the root. Zerolith does not
run npm install and does not execute any build script on its side. That is a choice, not an
oversight: a postinstall is arbitrary code, and running it for every deploy requires a sandbox
that does not exist yet.
Each site gets a name, which becomes its address:
https://<name>.sites.zerolith.fr
The name is 3 to 40 characters long (lowercase letters, digits, hyphens). Sites are not served
under zerolith.io: a site is arbitrary HTML written by someone other than us, and putting it
under the dashboard's domain would give every page the dashboard's reputation (and cookies).
Three ways to deploy
The dashboard. On the Sites page, you create the site, then drag the dist/ folder into the
drop zone. The files go straight to object storage without passing through the API, and the site is
live as soon as it is published.
An AI agent. With the MCP server connected, the deploy_site tool receives
the file contents in the conversation and publishes in one call, up to about 1.4 MB. Beyond that,
create_site_deploy returns presigned upload URLs that the agent fills with curl. In practice,
"build the project and publish it to my demo site" is enough.
The API. Three calls, and this is where you can see how it works:
# 1. The site
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"name": "demo"}' https://zerolith.io/api/sites
# 2. A deploy: the LIST of files, with their hash and size
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"files": [{"path": "index.html", "sha256": "9f2c…", "size": 812},
{"path": "assets/index-B3x9Kq1z.js", "sha256": "41ab…", "size": 70211}]}' \
https://zerolith.io/api/sites/<site_id>/deploys
# → { "deploy": {…}, "uploads": [ {"sha256": "…", "url": "https://…"} ] }
# 3. Upload each requested file (PUT to the presigned URL), then publish
curl -X POST -H "Authorization: Bearer $JWT" \
https://zerolith.io/api/sites/<site_id>/deploys/<deploy_id>/publishStorage is content-addressed: a file is stored under its SHA-256 hash. The response to step 2 therefore only asks for the files the site does not already have. Redeploying after fixing a typo in one page uploads one page, and an asset that is unchanged from one version to the next is stored only once. The presigned URL is signed for a specific size: a file larger than the one declared is rejected by the storage itself, not by a check on our side that could be skipped.
Two checks happen before anything is uploaded: a .env file (or .env.production, etc.) is
refused, because once published it would be readable by anyone, and a build that contains one
generally contains the keys it was built with. A .git folder is refused for the same reason.
What the site serves, exactly
The behavior is designed for an app with client-side routing, and to never lie about a missing file:
- an existing file is served with its MIME type and an
ETag(a reload gets a304); fingerprinted assets (index-B3x9Kq1z.js) go out withCache-Control: immutable, HTML withno-cache, so a new version is visible on the next reload; /docs/servesdocs/index.html, and/docsredirects to/docs/;- an unknown path without an extension (
/dashboard/settings) returnsindex.htmlwith a200: this is where the React or Vue router takes over; - a missing file WITH an extension (
/assets/old.js,/robots.txt) returns a real 404, with your404.htmlif you have one.
That last point deserves a closer look. Many hosts answer index.html to anything they cannot
find. The browser then receives HTML where it expected JavaScript, the page crashes on an
incomprehensible syntax error, and nothing in the logs says a file was missing. A 404 is
noticed immediately. The SPA fallback can be turned off per site if your site has no router.
All of this can be checked on a demo site,
zerolith-test.sites.zerolith.fr. The page tests itself,
from the browser: asset loading, an SPA route (/route/test), a missing file that must answer 404,
and an /api/myip/ call to a function on the same account. Open the network tab while it loads:
every request and its status code are there.
The backend on the same origin
This is why we built this service instead of pointing you to an existing host. Every request under
/api/<function>/ is forwarded to your function of the same name:
// No absolute URL, no CORS: it is the same origin.
const res = await fetch("/api/contact/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, message }),
});On the function side, it is an ordinary handler, which receives the rest of the path after
/api/contact:
def handler(request):
data = request.json()
# … save it, send an email, call a third-party API …
return {"ok": True, "received": data.get("email")}A few rules, all of which follow from the fact that a browser is a public client:
- Only public functions answer. A private function returns
401under/api/, and that is intended: the page has no way to keep an API key secret, anyone could read it in the network tab. A public function is rate-limited per function, not against your overall budget. - Routing is fixed.
/api/contact/…goes to the site owner'scontactfunction, and to no other. Nobody can use your site to serve another account's function. - No
/api/in your files. A file published underapi/would be hidden by the routing, so it is refused at deploy time rather than silently ignored.
For persistent state (sign-ups, a cart, scores), the function relies on the private database: the site, the API and the database live on the same account, billed on the same statement.
Versions and rollback
Each publication is a kept deploy. The trial account keeps 3, a paid account 10. Rolling back means republishing an earlier deploy: no new upload, the files are already there, and the switch is immediate. Beyond the limit, the oldest deploy that is not live is deleted, along with the files no remaining version uses. The live version is never deleted.
What it costs
| Trial | Paid account | |
|---|---|---|
| Sites | 1 | 10 |
| Storage per site | 10 MB | 500 MB |
| Files per deploy | 1,000 | 4,000 |
| Max file size | 25 MB | 25 MB |
| Deploys kept | 3 | 10 |
| Search engine indexing | no (noindex) |
yes |
Storage is free up to 10 MB per account, all sites combined. Beyond that, a paid account pays €0.40 per GB per month, prorated and settled daily. A trial account is never billed for its sites: its single site is capped at the free allowance. Traffic is not billed, but it is rate-limited per site, so that a heavily used site does not starve the others.
Let's do the math for a typical Vite app. A 2 MB build, ten versions kept, half of the assets
changing each time: about 11 MB stored, so 1 MB above the free allowance, or €0.0004 per month.
What will really cost money is the functions behind /api/, at the usual price, per second of pod
uptime (the details are on the pricing page).
The trial account's noindex is a safeguard against abuse, not a punishment: free, indexed hosting
is exactly what authors of phishing pages look for. It goes away on the first credit top-up.
What the service does not do (yet)
Better to say it plainly than to let you find out:
- No build. You have to publish the built folder. An AI agent connected to the MCP can run the build on your machine and then publish, which covers most cases.
- No server-side rendering. A Next.js app with SSR does not work; with
output: "export", it does. - No configurable redirects or headers in the style of
_redirects/_headers. - No global CDN. Sites are served from our servers in France, with an in-memory cache in front of the storage. For a European audience, that is fast. For a worldwide audience, a CDN in front of the site is still a good idea.
What's next
A site can also be served under your own domain: you attach it from the Sites page, prove you
own the zone with a TXT record, point a CNAME to <name>.sites.zerolith.fr, and the TLS
certificate is issued automatically. Same price as a domain on a function, €1 per month. The
details are in the documentation.
The shortest way to try it is still the dashboard: create an account, create a site, drag in the
dist/ folder. The credit offered at sign-up amply covers the functions a small app will call
behind it.
Try it on your own account
Signing up comes with credit, enough to deploy, schedule and measure everything above, without getting a card out.
Start for free »