zerolith

← All articles

FrontendTutorial

Hosting a static site with its API on the same origin

8 min read

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:

site 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:

deploy through the API
# 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>/publish

Storage 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 a 304); fingerprinted assets (index-B3x9Kq1z.js) go out with Cache-Control: immutable, HTML with no-cache, so a new version is visible on the next reload;
  • /docs/ serves docs/index.html, and /docs redirects to /docs/;
  • an unknown path without an extension (/dashboard/settings) returns index.html with a 200: 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 your 404.html if 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:

from a page of the site
// 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:

main.py
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 401 under /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's contact function, and to no other. Nobody can use your site to serve another account's function.
  • No /api/ in your files. A file published under api/ 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 »