Static demos

A built folder of HTML and JavaScript: no server, no container.

On this page

A static demo is a built folder of HTML, CSS and JavaScript with an index.html at its root: most single-app demos, anything made with Vite, or a Next app exported as files. It needs no server and no container, so it costs almost nothing to run and opens instantly, and it is what the free plan is built around.

This page covers what pitch deploy does with a static build, the tools pitch init recognises, base paths and client-side routing in a room, how the files are served, the limits, and deploying a small static demo from a chat with Claude.

{
  "$schema": "https://getroomi.com/schema/pitch.json",
  "name": "Ledgerline",
  "kind": "static",
  "output": "dist",
  "apps": [{ "id": "app", "name": "Ledgerline", "path": "/", "device": "laptop" }]
}

output is the folder the build writes, relative to pitch.json, with an index.html in it (output). It must be inside the project.

What pitch deploy does with it

  1. If package.json has a build script, it runs it with the package manager the lockfile names: pnpm-lock.yaml for pnpm, yarn.lock for yarn, bun.lockb or bun.lock for bun, and npm otherwise. It does not install dependencies first; install them as you normally would. With no build script, the folder is deployed as it is.
  2. It requires output/index.html to be there afterwards.
  3. It scans the built folder for secrets. Anything in it is sent to every viewer's browser, so a key in it is a key published.
  4. It packs the folder and prints what would go live. Nothing has left your machine.
  5. On --commit, it uploads the folder as it was built and scanned, uploads the pages pitch.json lists, and makes the build live.
pitch deploy
  ok   manifest: pitch.json validates (static, 1 app)
  note story: there is none, so the room offers no guided tour. A viewer meets the demo without a word from you
         https://getroomi.com/docs/story
  ok   secrets: gitleaks scanned the project and found nothing
  ok   build: npm run build
  ok   secrets: gitleaks scanned the build output (dist) and found nothing

  What would go live: project "ledgerline" at …
    Ledgerline — static, from dist/
    artefact 412.6 KB (422502 bytes)
    apps:
      app          /            laptop  Ledgerline

  Rehearsal only: nothing has left this machine. `pitch deploy --commit` publishes exactly this.

A static rehearsal needs neither Docker nor a server. It builds and scans; it does not open the demo, so check it in a browser yourself first (vite preview, npx serve out) the way a viewer will see it.

What pitch init reads

pitch init recognises three kinds of static build. A repository with a Dockerfile, or with a server framework (Hono, Express, Fastify, Koa) at its root, is read as a container demo instead.

  • Vite: vite in the dependencies. output is the outDir in vite.config.ts, .js or .mjs, else dist.
  • Next: next in the dependencies and output: 'export' in next.config.ts, .mjs or .js. output is where next build writes the export: its distDir when one other than .next is set, else out. pitch init reads distDir written as a string (distDir: 'build'), or as process.env reads joined by ??, || and ? :, directly or through a const, with the variables your build script sets before next build (NEXT_DIST_DIR=.next-build next build, or after cross-env); anything else unset. It reads the config and never runs it. A distDir it cannot read (a function call, a path joined in code, an import) is a question, not a guess: it says so, and with nobody at the terminal it stops and asks for --output, even with --yes. A Next app without output: 'export' needs a server; pitch init says so, and leaves the kind for you to choose.
  • Plain HTML: an index.html at the root and no Vite. output is ., the folder itself.

For an app in a workspace (apps/<name> or applications/<name>), the output folder is inside that app's folder, and output says so: apps/web/dist.

Vite

Vite's defaults work as they are: the app is built into dist, and the room serves it at the root of the demo's origin.

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({ plugins: [react()] })
pitch init --kind static --output dist --device app=laptop

A Vite app whose data comes from its own mock API needs that API in the browser for a static build: fixtures imported as JSON, a mock service worker, or data kept in memory. A demo that needs a real server is a container demo.

Next.js static export

A Next app that renders nothing on request can be exported as files:

// next.config.mjs
export default {
  output: 'export',
  trailingSlash: true,
  images: { unoptimized: true },
}
  • output: 'export' writes the site to out when next build runs, and is what pitch init looks for.
  • trailingSlash: true writes each route as a folder with its own index.html (out/pricing/index.html) rather than out/pricing.html. The room serves /pricing from pricing/index.html, and never looks for pricing.html, so without it a viewer who reloads /pricing is given the home page's file (Client-side routing).
  • images: { unoptimized: true } is Next's own requirement for next/image in an export: there is no image server.
{
  "$schema": "https://getroomi.com/schema/pitch.json",
  "name": "Harbour Books",
  "kind": "static",
  "output": "out",
  "apps": [{ "id": "app", "name": "Harbour Books", "path": "/", "device": "laptop" }]
}

Anything that needs Next's server (API routes, server actions, pages rendered on request) does not survive an export. Deploy that as a container demo.

Columbus, exported

Columbus, the Quickstart's demo, is a Next 16 app with dynamic routes (/transactions/[id]/[tab]), next/image, and a theme read from a cookie on the server. What it took to export it:

  • output: 'export', trailingSlash: true, images: { unoptimized: true } in next.config.ts, as above. Its dynamic routes already listed every page with generateStaticParams, which an export needs.

  • No cookies() or headers(). The root layout read the theme from a cookie on each request, and the export stopped at the first page:

    Error: Route /portfolio/[id]/[tab] with `dynamic = "error"` couldn't be rendered statically because it used `cookies()`.

    Every page is now built in the default theme, and one line of inline script in <head> applies the stored theme before the page paints. In a room the demo is framed from another site, so a browser that blocks third-party cookies does not keep the choice between loads.

  • Where the export lands. Next writes an export to out, or to distDir when one is set, and its own working files to .next either way. Columbus's build script sets NEXT_DIST_DIR=.next-build and next.config.ts reads it into distDir, so the site is in .next-build: output says so, and pitch init finds it from those two files.

  • next start does not serve an export, so Columbus's start script went. Look at the folder with any static server before deploying it.

{
  "$schema": "https://getroomi.com/schema/pitch.json",
  "name": "Columbus",
  "kind": "static",
  "output": ".next-build",
  "apps": [{ "id": "columbus", "name": "Columbus", "path": "/", "device": "laptop" }]
}

There is nothing to clear between deploys. Next writes keys of its own into .next on every next dev and next build, which a secret scanner rightly calls keys, so the rehearsal's scan of the project leaves out the folders tools generate (The secret scan); the scan of the build output reads every file that would be uploaded.

Plain HTML

A folder with an index.html and whatever it links to deploys as it is. With no package.json, or one without a build script, nothing is built.

{
  "$schema": "https://getroomi.com/schema/pitch.json",
  "name": "Northwind Onboarding",
  "kind": "static",
  "output": "site",
  "apps": [{ "id": "app", "name": "Onboarding", "path": "/", "device": "phone" }]
}

Other tools

Any tool that writes a folder of files with an index.html at its root can be deployed: set kind to static and output to the folder your tool writes. pitch init does not recognise it on its own, so it asks, or takes --kind static --output <dir>. Check your tool's documentation for its output folder and for how it writes routes: one folder with an index.html per route works with the room's routing; one file per route (about.html) does not work for a viewer who reloads that route.

Base paths and several apps

Each app in apps has a path, and the room opens the app at that path on the demo's origin (apps[].path). For a static build, the path is also where its files are in the output folder: the room serves /dashboard/assets/app.js from output/dashboard/assets/app.js.

A single app at / needs nothing special. Several apps in one static build each live in a folder of the output named for their path, each built with that path as its base:

dist/
  index.html            the phone app, at /
  assets/
  dashboard/
    index.html          the dashboard, at /dashboard/
    assets/
// dashboard/vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({ base: '/dashboard/', build: { outDir: '../dist/dashboard' } })
{
  "$schema": "https://getroomi.com/schema/pitch.json",
  "name": "Ledgerline",
  "kind": "static",
  "output": "dist",
  "apps": [
    { "id": "app", "name": "Ledgerline", "path": "/", "device": "phone", "default": true },
    { "id": "dashboard", "name": "Ledgerline Dashboard", "path": "/dashboard/", "device": "laptop" }
  ]
}

An app built with the wrong base loads its index.html and then none of its scripts or styles: they are asked for at /assets/… and belong at /dashboard/assets/….

Client-side routing

A single-page app's own deep links work in a room. For a request, the room tries, in order:

  1. the path itself: /orders/42;
  2. that path as a folder: /orders/42/index.html;
  3. the index.html of the app whose path the request is under, the longest match: /index.html for an app at /, /dashboard/index.html for one at /dashboard/.

So a viewer who reloads /orders/42, or opens it in a new tab, is given the app's index.html, and the app's router draws the right screen. History-mode routing (React Router, Vue Router, TanStack Router) and hash routing both work.

The same fallback answers any missing file under an app's path, so a script or image the build did not write is answered with index.html rather than a 404. A blank screen and a console error about a script's MIME type usually mean a file is missing or the base is wrong.

A story step's path is held to the first two only: it must be a file the build wrote, the path itself or the index.html inside it, and pitch deploy stops if it is not. The fallback would answer a mistyped page too, so it cannot show that a page is there. A Next export writes a file for every page, so each is a step's page as it is; a route a single-page app draws only in the browser is not, so open the app at its own path and let the viewer click on from there.

How the files are served

  • Only GET and HEAD. Any other method is answered 404: there is no server to receive it.
  • With a content-type from the file's extension: html, js, mjs, css, json, svg, png, jpg, jpeg, gif, webp, avif, ico, woff, woff2, txt, webmanifest, wasm and map. Anything else is served as application/octet-stream, with x-content-type-options: nosniff.
  • With cache-control: no-cache, so a new build is what the viewer gets on their next load.
  • From the demo's own origin (<project>--demo.getroomi.app), framed by the room and by nothing else.
  • With one script added to each HTML page, /__pp/where.js, which tells the room which screen the viewer is on, for pinning comments, and rings what the tour points at.

The files are the same for every viewer. State the app keeps in the browser (memory, localStorage) is each viewer's own.

The secret scan

The rehearsal runs gitleaks, with its default rules, over the project folder and then over the built output folder, and stops at any finding. The built folder is what matters most: bundlers inline environment variables into the JavaScript they write (import.meta.env.VITE_…, process.env.NEXT_PUBLIC_…), so a key in a .env file can end up in a file every viewer downloads.

The project scan leaves out what tools generate while they build and cache: .next and a distDir of its own such as .next-build, .turbo, .vercel/output, .nuxt, .output, .svelte-kit, .astro, .parcel-cache, .cache, .wrangler, .angular and .expo. Next, for one, writes preview and server-action keys of its own into .next every time it runs, and a scan that read them would refuse every Next app that had ever been built. Nothing that ships is missed by it: the scan of the built output reads every file that would be uploaded, whatever folder the build wrote it to. The rest of .vercel, where vercel pull writes real environment files, is scanned.

  FAIL secrets: gitleaks found secret(s) in the build output (dist):
         gcp-api-key in assets/index-4f1c2a.js:1
         Values redacted. Remove it, then rotate it: it has been on disk in plain text.

Only a clean scan passes, and a scanner that could not run stops the deploy too. Container demos has the details.

Limits

What Limit
The folder, as uploaded (gzipped) 512 MiB (536,870,912 bytes)
Files 5,000
All files, unpacked 256 MiB (268,435,456 bytes)
One file 32 MiB (33,554,432 bytes)
One path 400 characters
Deploys 2 in any 24 hours on Free; no limit on paid plans

The platform unpacks the folder when the build is registered and refuses the build, keeping nothing of it, if any entry is not a plain file or a folder: a symbolic link or a hard link in the output is refused. So is a path that is absolute or climbs out of the folder with ...

What it cannot do

There is no server to call, so a static build has no controls, and the steps of its story show an app and point at part of it but have no run. Everything the demo does happens in the viewer's browser, from the files. pitch validate refuses either:

  pitch.json does not validate: 1 problem
    controls: a static build has no server to call; controls need kind: container
      https://getroomi.com/docs/pitch-json#field-controls

From a chat, with no terminal

Claude in chat can deploy a static demo it has in the conversation, through the connector's deploy_static tool: a page or a small app it wrote there, with its tour and its pages. The connector teaches Claude the rules when it connects, check_bundle names every problem with its fix before anything is sent, and deploy_static rehearses first and publishes only on your yes. See Deploying from Claude.

deploy_static takes What it is
project The project's address: an existing one, or a new one, which it creates. Lower-case letters, digits and hyphens, 3 to 40, starting with a letter
name The demo's name in the room, 1 to 80 characters
device The frame when there is no manifest: phone, tablet, laptop (the default), screen or none
files Each file's path, relative to the root, and its content, as text (utf8, the default) or base64
manifest Optional. pitch.json as an object: apps, story and pages. The files are the build, so there is no output
note Optional. A note kept with the build, up to 200 characters
commit false (the default) rehearses; true publishes

The rules are this page's, with smaller limits, because the files travel in the conversation:

  • at most 200 files, and 3 MB in all;
  • every app's path a file among them, the path itself or the index.html inside it: index.html at the root for an app at /, which is the one app there is without a manifest;
  • every story step's path a file among them, in the same way;
  • what a browser runs: the room builds nothing, so source such as a .tsx file is served as it is;
  • paths relative, up to 255 bytes, with no . or .. segments, no hidden files (a .env has no place in a demo), no node_modules, no backslash, and no file name over 100 bytes;
  • no file sent twice;
  • no file that looks like a live credential: a private key, or an AWS, Stripe live, GitHub, Slack, Anthropic, OpenAI or Google key. This is a short list of common shapes, not the full gitleaks scan the CLI runs.

A publish counts as a deploy, against the same daily limit. A demo on disk, or one bigger than this, goes through pitch deploy, which builds it and scans it first. The connector's refusal says how to install it, npm install -g @pitch-product/cli and then pitch login, and in Claude Code, Claude offers to run the install for you (When Claude suggests the CLI).

Common failures

FAIL secrets: gitleaks found secret(s) in the build output. Something the build wrote into the folder that would be uploaded looks like a key: an environment variable inlined into a script, usually. Every file that would leave is scanned, generated or not. Remove it from the build, then rotate it: it has been on disk in plain text.

FAIL build: dist/index.html is missing after the build. The build wrote somewhere else, or failed quietly. Check output against where your tool writes, and run the build script yourself.

FAIL build: \npm run build` exited 1`. The build failed; its last lines are printed under the failure. Dependencies not installed is the usual cause: pitch deploy runs the build, it does not install.

The page loads blank in the room. The app was built for another base path, or asks for its files by an absolute URL on another host. Open the browser's console in the room's demo frame.

The build is refused after upload with "a symlink". The output folder holds a symbolic link. Copy the file in its place.

A deep link shows the home page. The tool wrote the route as a file (about.html) rather than a folder (about/index.html). For Next, set trailingSlash: true.