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
- If
package.jsonhas abuildscript, it runs it with the package manager the lockfile names:pnpm-lock.yamlfor pnpm,yarn.lockfor yarn,bun.lockborbun.lockfor 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. - It requires
output/index.htmlto be there afterwards. - 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.
- It packs the folder and prints what would go live. Nothing has left your machine.
- On
--commit, it uploads the folder as it was built and scanned, uploads the pagespitch.jsonlists, 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:
vitein the dependencies.outputis theoutDirinvite.config.ts,.jsor.mjs, elsedist. - Next:
nextin the dependencies andoutput: 'export'innext.config.ts,.mjsor.js.outputis wherenext buildwrites the export: itsdistDirwhen one other than.nextis set, elseout.pitch initreadsdistDirwritten as a string (distDir: 'build'), or asprocess.envreads joined by??,||and? :, directly or through aconst, with the variables yourbuildscript sets beforenext build(NEXT_DIST_DIR=.next-build next build, or aftercross-env); anything else unset. It reads the config and never runs it. AdistDirit 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 withoutoutput: 'export'needs a server;pitch initsays so, and leaves the kind for you to choose. - Plain HTML: an
index.htmlat the root and no Vite.outputis., 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=laptopA 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 tooutwhennext buildruns, and is whatpitch initlooks for.trailingSlash: truewrites each route as a folder with its ownindex.html(out/pricing/index.html) rather thanout/pricing.html. The room serves/pricingfrompricing/index.html, and never looks forpricing.html, so without it a viewer who reloads/pricingis given the home page's file (Client-side routing).images: { unoptimized: true }is Next's own requirement fornext/imagein 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 }innext.config.ts, as above. Its dynamic routes already listed every page withgenerateStaticParams, which an export needs.No
cookies()orheaders(). 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 todistDirwhen one is set, and its own working files to.nexteither way. Columbus'sbuildscript setsNEXT_DIST_DIR=.next-buildandnext.config.tsreads it intodistDir, so the site is in.next-build:outputsays so, andpitch initfinds it from those two files.next startdoes not serve an export, so Columbus'sstartscript 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:
- the path itself:
/orders/42; - that path as a folder:
/orders/42/index.html; - the
index.htmlof the app whosepaththe request is under, the longest match:/index.htmlfor an app at/,/dashboard/index.htmlfor 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
GETandHEAD. Any other method is answered 404: there is no server to receive it. - With a
content-typefrom the file's extension:html,js,mjs,css,json,svg,png,jpg,jpeg,gif,webp,avif,ico,woff,woff2,txt,webmanifest,wasmandmap. Anything else is served asapplication/octet-stream, withx-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-controlsFrom 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
patha file among them, the path itself or theindex.htmlinside it:index.htmlat the root for an app at/, which is the one app there is without a manifest; - every story step's
patha file among them, in the same way; - what a browser runs: the room builds nothing, so source such as a
.tsxfile is served as it is; - paths relative, up to 255 bytes, with no
.or..segments, no hidden files (a.envhas no place in a demo), nonode_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.