Sites

A site is static content you uploaded: a built React app, a landing page, documentation. It is served from storage on its own address, not from the app domain.

The address of a site

Every site gets its own subdomain:

app.oxta.io                  the app
myapp.sites.oxta.io          your site

That is a different origin from the app: the app domain holds your token in the browser, and if the site HTML came from there, its scripts could read that token. For the same reason HTML and SVG in ordinary files are served as a download rather than a page — there is no separate origin there.

Because the subdomains differ, sites are isolated from each other as well: one site's script cannot see another one's storage.

The app never answers on those addresses: myapp.sites.oxta.io/api/…, /flows, anything at all returns 404. So a token cannot land in that origin.

A site used to be served at sites.oxta.io/myapp/ as well. That is gone: in that shape every user's site shared one origin, so the scripts of one site could read another site's storage and call its /_api/… as the visitor. Links that were already shared still work — they are redirected to the subdomain for good (301, or 308 for requests with a body, so the method survives).

Building

On a subdomain the site sits at the root, so an ordinary build with absolute paths (/assets/app.js) works as it is — nothing to configure.

The site sits under a prefix only locally, at /__site/myapp/localhost has no subdomains. There we inject <base href="/__site/myapp/"> so relative paths resolve; absolute ones <base> cannot fix. So if you check a build locally, build it with a relative base: vite build --base=./ (or "homepage": "." in Create React App). In production this is not needed.

The address

Lowercase letters, digits and single hyphens, 3 to 40 characters. Some names are ours: api, app, www, help, sites and the like.

Publishing

Press "Publish a folder" and pick your build directory — the browser hands over every file with its relative path. When everything sits in one folder (dist/), that folder is stripped, so dist/index.html becomes index.html.

Publishing the same path again overwrites the file. Files missing from a later upload stay where they are — to clear them out, remove the site and create it again.

Caps: 25 MB per file, 100 MB and 500 files per site.

Publishing a site together with its flows

A site and its API can travel as one pack. Put a _flows folder next to the build, drop a JSON flow into it per file, and press "Publish a folder" as usual:

dist/
  index.html
  assets/app.js
  _flows/
    get.index.json          → GET  /_api/
    post.orders.json        → POST /_api/orders
    orders/get.all.json     → GET  /_api/orders/*
    _nightly-report.json    → a flow with no path of its own

The upload splits in two: everything outside _flows is published as site files, and every .json inside it is imported as a flow and pointed at a path. Nothing from _flows is ever published as a site file — neither the flows nor a stray notes.txt you left there, which is reported back rather than uploaded.

What the file name says

The name of the file is the route:

  • <method>.<name>.json — the method is one of get, post, put, patch, delete, any; without it the path answers to any;
  • folders nest into the path: _flows/admin/get.users.json becomes GET /admin/users;
  • index means the folder itself: _flows/get.index.json is GET /, _flows/orders/get.index.json is GET /orders;
  • all means a wildcard: _flows/orders/any.all.json is ANY /orders/*;
  • a name that starts with an underscore (_nightly-report.json), or anything inside a folder that starts with one (_flows/_lib/…), is imported as an ordinary flow with no path — that is where a scheduled flow or a subflow goes.

A flow can also say it outright, and then the file name does not matter:

{ "name": "Orders", "route": { "method": "POST", "path": "/orders" }, "nodes": [] }

"route": false imports the flow without a path.

The name a flow gets

The name of the site is added in front, so the flows of a pack stay together in the list: Orders from the shop site becomes Shop · Orders. Publishing the pack again does not stack the prefix twice.

Publishing the pack again

A second publish updates what is already there rather than creating duplicates:

  • a flow file whose path is already routed on this site updates the flow that path points at;
  • a flow with no path is matched by its prefixed name;
  • anything else is created, and its path is added to the site.

Values of secret variables you filled in stay as they were — an empty secret in the file does not wipe them.

A flow file that does not hold together is reported back and the rest of the pack still goes up: the answer names the file and what is wrong with it.

Caps: up to 50 flows in one pack and 50 paths per site.

Single page apps

The "single page app" switch decides what an unknown path does:

  • on — a path with no extension (say /orders/42) returns index.html and your script does the routing;
  • off — such a path returns 404.

A missing file with an extension (/assets/app.js) always returns 404 — otherwise a broken script path would quietly return HTML.

What the headers say

  • X-Robots-Tag: noindex, nofollow — sites stay out of search;
  • Cache-Control depends on the file: HTML is revalidated every time, hashed filenames are cached for a year;
  • ETag from R2, so a repeat request gets a 304;
  • ranges are supported, so video and audio can seek.

Your own API on the same origin

A site can have its own API: you point a path at a flow, and a call to /_api/… runs it.

POST https://myapp.sites.oxta.io/_api/orders   → your flow

Because this is the same origin as the page, the frontend needs no CORS and no token in its code.

  • a path looks like /orders or /orders/*; an exact path beats a wildcard, a longer wildcard beats a shorter one;
  • the method can be specific or ANY; a specific one wins;
  • unlike a webhook, such a call waits for the answer by default (15 s), because the browser needs it. Send Prefer: respond-async to skip waiting and get 202;
  • the answer comes from the flow's Response node; without one the caller gets {"ok": true};
  • the flow must be yours and switched on: another owner's flow cannot be routed, a switched-off one answers 503;
  • every call is a flow run, so it spends the runs quota. The rate limit comes from the flow's own settings, or the platform default of 60 requests per minute when there is none: over the limit you get 429 with Retry-After.

What the flow sees in its trigger: headers, query, body, method and path (already without /_api).

These paths are handled before the single page fallback, so an unknown /_api/… answers 404 JSON rather than index.html.

Trying it locally

The public host does not exist in development, so a site opens under a prefix instead:

http://localhost:8787/__site/myapp/
http://localhost:8787/__site/myapp/_api/orders

The Open button in the card uses that address by itself while you are on localhost — the card then shows an "opens locally" badge.

This form works only on localhost. It is switched off on the real domain: otherwise the site HTML would run on the app origin and could read your token.

What a plan includes

The limit is on the number of sites and on the total size of files across all of your sites:

PlanSitesStorage
Free125 MB
Starter3100 MB
Pro10500 MB
Business502 GB
Scale20010 GB

Visits to a site are not metered — static content is far cheaper than a flow run. Every /_api/… call is a flow run, so it does spend the runs quota.

The Sites page shows how much of the plan is already used. Over the limit a site is not created and an upload is refused, with the plan named in the message.

The switch

The "published" switch turns a site off at once: every path answers 404 while the files stay in place. That is the thing to reach for if someone complains about a site.