---
title: "Frontend hosting (Sites)"
description: "How Plurabase Sites serves a prebuilt static frontend next to its backend: ZIP rules, the runtime config file, rollback and current limits."
slug: "hosting-sites"
locale: "en"
group: "platform"
order: 2
last_reviewed: 2026-10-20
---

# Frontend hosting (Sites)

> Status: Available in pilot (sites.zip)

A Site is the static frontend of a project. You upload a prebuilt ZIP in the console, publish it, and Plurabase serves it with a generated configuration file that connects it to the project's backend. Publishing or rolling back a Site never changes the database. In the pilot the operator enables Sites for each project. Status words are defined on the [facts page](/docs/facts/#status-words).

## Publish a release

1. Build the frontend into static files with `index.html` at the root.
2. Zip the build output (not the folder that contains it).
3. In the console, open **Sites** (`/platform/sites/`), select the project and create the Site.
4. Upload the ZIP and choose **Publicar** (the Sites screens are in Spanish). The console reads the published bytes and configuration back over HTTP before it reports success.
5. To go back, select a previous release and choose **Volver a esta versión**.

## Connect the frontend to its backend

Each release is served with `./__simplybase/config.json`, which holds `api_url` and `publishable_key` (the anon key) plus `project_ref`, `release_id` and `version` (the file's format number), never a secret key:

```ts
import { createClient } from '@supabase/supabase-js'

export async function connect() {
  const reply = await fetch('./__simplybase/config.json', {
    credentials: 'omit',
    cache: 'no-store',
    redirect: 'error',
  })
  if (!reply.ok) throw new Error('Project connection unavailable')
  const config = await reply.json()
  return createClient(config.api_url, config.publishable_key, {
    auth: { persistSession: false, detectSessionInUrl: false },
    global: {
      fetch: (input, init) => fetch(input, { ...init, credentials: 'omit' }),
    },
  })
}
```

Call `connect()` once at start-up and keep the client it resolves to. The code sits in a function because the only checked build is an esbuild IIFE bundle, which rejects top-level `await`.

## Rules for the ZIP

- `index.html` at the root and ASCII file names. Accepted file types: `.html`, `.css`, `.js`, `.mjs`, `.json`, `.map`, `.webmanifest`, `.txt`, `.xml`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.avif`, `.ico`, `.woff`, `.woff2`, `.ttf`, `.otf` and `.wasm`.
- Each release is served under its own path, `/__sites/r/<release>/`, and paths outside it return an error. The URLs of your own files in HTML, CSS and JavaScript must therefore be relative (`./assets/app.js`, `../logo.svg`); a root-relative URL such as `/assets/app.js` fails.
- The Content Security Policy loads scripts, stylesheets and fonts only from files in the release. Inline `<script>` and `<style>` elements, `style="…"` attributes, inline event handlers, `eval` and `new Function`, `data:` fonts, WebAssembly compilation and third-party CDNs are blocked. Images can be files or `data:` URLs. Bundle every dependency, including `@supabase/supabase-js`.
- The page runs in a sandbox without form submission: a `<form>` never fires its `submit` event, so start actions from a button's `click` and the Enter key.
- The sandbox also blocks `alert`, `confirm` and `prompt` (`confirm` returns `false` at once, so ask for confirmation with a dialog inside the page), popups and new-tab links (`window.open`, `target="_blank"`) and downloads (`<a download>`).
- Browser requests (`fetch`, XHR, WebSocket) reach only the release and the project API, never a third-party API. Iframes, audio and video, web workers and `<style>` elements added at runtime, as CSS-in-JS libraries such as emotion and styled-components do, are blocked.
- No server-side rendering, build step, package installation or custom response headers.
- No SPA fallback: a deep link to a client-side route returns 404. Use hash-based routing or link to `index.html`.
- Site code runs on an opaque origin, so it cannot use cookies, `localStorage` or service workers. Users sign in again after a reload.

## Framework builds

Sites has been checked with one build: the console's starter, a classic script bundle (esbuild, IIFE format) and a stylesheet, both referenced as `./assets/…`, with `@supabase/supabase-js` bundled in. Judge any other output by the rules above:

| Build | On Sites | Why |
| --- | --- | --- |
| esbuild bundle referenced with `./` paths, like the starter | Checked | Classic script, external stylesheet, relative URLs |
| Vite single-page app (React, Vue, Svelte) | Not checked | Needs `base: './'`, `build.assetsInlineLimit: 0` (so small fonts stay files) and no `@vitejs/plugin-legacy` (it adds inline scripts). Its `<script type="module">` output has not run on Sites yet. |
| Vue templates compiled in the browser | Blocked | The runtime compiler uses `new Function`; precompiled single-file components do not |
| Astro static output | Not supported | Root-relative `/_astro/` URLs, small stylesheets inlined by default, islands started by inline scripts |
| Next.js with `output: 'export'` | Not supported | Inline hydration scripts and root-relative `/_next/` URLs |

Check the build directory before uploading, because each Site accepts two uploads in its lifetime, failed ones included. Each command must print nothing (replace `dist` with your output directory; the last one skips JSON data blocks such as `<script type="application/json">`, which are allowed):

```sh
grep -rnoE '(src|href)=[^.a-zA-Z0-9]?/' dist --include='*.html'   # root-relative URLs
grep -rnoE 'url\([^.a-zA-Z0-9]?/' dist --include='*.css'          # root-relative URLs in CSS
grep -rnoE '<style|style=' dist --include='*.html'                # inline styles
grep -rnoE '<script[^>]*>' dist --include='*.html' | grep -v 'src=' | grep -vE 'type="application/(ld\+)?json"'   # inline scripts
```

## Limits

- 4 MiB ZIP, 16 MiB expanded.
- Two uploads per Site in its lifetime, including failed ones, and two retained releases.
- Pausing the project also stops its Site; the releases stay valid after a resume.

## What is not available yet

> Status: In development (sites.every-project)

Sites for every project (ADR-0037) is in the repository and has not run on a host. It gives each project's Site its own browser origin instead of a path under the project's API host:

- On the private network a Site gets its own port on the pilot's Tailscale hostname. It keeps the sandbox and the rules above, because a port shares the console's hostname and cookies, and only the operator's own organizations get Sites there. Any web page that the operator's browser opens on the private network can read those Sites, configuration file included, so put nothing on them that you would not publish.
- On a public sites domain a Site gets its own hostname (`s-<site id>.<sites domain>`). There the sandbox is gone: the page can use `localStorage`, its own cookies, forms, dialogs, popups and downloads. Scripts load from the Site and from inline `<script>` elements that are hashed at serve time; styles may be inline. The head release is served at `/`, query strings are ignored for lookup (so `?code=` sign-in redirects work) and an extensionless path falls back to `index.html`.
- Still blocked on a public Site: inline event handlers (`onclick="…"`) and `javascript:` URLs, `eval`, `new Function` and WebAssembly compilation, service workers, and being framed by another page.
- Inline scripts are hashed only when the page reads unambiguously. A page larger than 1 MiB, with more than 64 `<script>` elements (external ones count), with a `<script>` inside an HTML comment, or with `<!--` or `<script` inside a script's text gets no hashes, so none of its inline scripts run. Move such code to `.js` files.
- The owner sets an allowlist of up to 16 https origins for API calls, scripts, styles, fonts, media, frames and form targets, through the `get_site_policy` and `set_site_policy` commands. The console has no screen for it yet.
- Upload limits come from the organization's plan, at most 4 MiB ZIP and 16 MiB expanded, with a daily number of uploads and publications instead of two uploads per lifetime.
- Public Sites cannot reach their project API yet: project APIs are only on the private network, so the configuration file is withheld there.
- Until the sites domain is listed in the Public Suffix List, and in browsers whose copy of that list predates the listing, every Site on it shares one registrable domain: any Site can read and overwrite a cookie another Site sets with `Domain=`. Until the listing, only the operator's own organizations get public Sites. Use `__Host-` cookies or `localStorage`, never a cookie with `Domain=`.
- A Site keeps being served while its project's backend is paused or busy with a lifecycle operation. A rotated project key stops it until it is published again.

The public edge (TLS certificates and DNS) is off until the owner approves public exposure.

> Status: In development (sites.git)

> Status: In development (sites.domains)

> Status: Planned (sites.previews)

Git deploys from GitHub are in development: a GitHub App connection, a reviewed build recipe, builds in an isolated virtual machine and automatic publication on push. Custom domains are in development: the serving code exists and the public edge is off. Preview deployments are planned. During the pilot, Sites are served on a private Tailscale hostname.

> Status: Planned (access.public-domain)

The planned public layout gives customer frontends their own registrable domain (`s-<site>.plurabase.app`), separate from the console (`app.plurabase.com`) and the project APIs (`<project_ref>.api.plurabase.com`), so customer JavaScript can never read console cookies. None of these domains serve traffic yet.
