Three ways to isolate a preview, and the Cloudflare wildcard trick
When an in-browser IDE runs your dev server and shows you the result, the
result has to be served from somewhere. The easy answer is the origin you
already have: put the preview at /preview/5173/ on the IDE's own domain, let
the Service Worker route by path, ship it.
That is what we did, and it is a security problem.
Same origin means the same cookie jar, the same localStorage, the same
IndexedDB, the same OPFS, the same Cache Storage, the same Service Worker scope.
Preview code, which includes every npm package the project installed and
anything an AI assistant just generated, sits inside the IDE's origin. It can
read the editor's session state, corrupt its persistence, and call its
same-origin APIs. Previews are not isolated from the IDE, and they are not
isolated from each other.
Fixing this properly took three attempts, and each one ran into a different piece of web platform trivia.
Mode A: same origin
The default, and the one to move away from.
https://ide.example.com/preview/5173/
The Service Worker intercepts requests under /preview/<port>/, strips the
prefix, and relays them to the kernel, which it can find directly because it
is same-origin with the tab holding it.
Zero extra infrastructure, and it works. But beyond the storage problem, path routing quietly breaks things that a real server would get right:
| Keyed on origin | Breaks under shared-origin path routing |
|---|---|
Cookie jar (session, CSRF, SameSite) | frontend and backend cookies collide at / |
| localStorage / IndexedDB / OPFS / Cache | services share one store, state bleeds |
CORS and fetch credentials | cross-service calls look same-origin, wrongly |
| Service Worker scope | the app's own SW registrations collide |
| Absolute paths | router basename, /asset.png and <base> all break |
That last row is the one users actually report. Any app that assumes it is
served from / needs a prefix hack somewhere: in the SW, in the URL rewriter,
or in the app's own config.
Mode B: a second origin
Move previews to a different origin entirely and the browser's same-origin
policy does the work for you. The kernel still lives in the IDE tab, so the
preview's Service Worker reaches it through a hidden bridge iframe and a
MessagePort.
Here the hosting platform pushes back. A Cloudflare Pages project only gets
<project>.pages.dev, and you cannot mint preview.myproject.pages.dev. A
second origin means a second Pages project.
Which sounds fine, and introduces the first real trap.
pages.dev is on the Public Suffix List. That makes myproject.pages.dev
and myproject-preview.pages.dev different sites, not merely different
origins. As an isolation boundary that is stronger than you asked for: cookies
cannot be shared even deliberately.
It also breaks popping a preview out into its own tab, in a way that cannot be worked around.
Chrome storage-partitions cross-site contexts. The kernel is reached through a
bridge iframe living in the editor tab; for a standalone preview tab to use that
bridge's Service Worker registration and MessagePort, both have to be in the
same storage partition. Cross-site, they are not. And requestStorageAccess()
un-partitions cookies, not Service Worker registrations, so the "connect
this tab to its project" gate can never actually bridge the two partitions. The
gate appears, you grant it, and nothing changes.
Two subdomains of one registrable domain fix it. Serve the IDE at
ide.example.com and previews at preview.example.com, both CNAMEd to their
Pages projects, and the two are same-site: no partition wall exists, so the
popped-out tab shares the bridge's Service Worker and reaches the kernel with no
gate at all. Storage is still origin-scoped, so preview code still cannot touch
IDE storage.
| Deploy | Same-site? | Partitioned? | Pop-out |
|---|---|---|---|
Two *.pages.dev projects | No, PSL cuts pages.dev | Yes | Gate appears, cannot be granted |
| Two subdomains of one domain | Yes | No | Connects immediately, no gate |
StackBlitz (stackblitz.com / webcontainer.io) | No, different domains | Yes | Gate, accepted deliberately |
The residual leak with same-site subdomains is domain-wide cookies: anything set
with Domain=example.com is visible to both. So do not set domain-wide cookies
on the IDE. For trusted first-party code this is the right trade. For untrusted
code at scale you want the cross-site boundary and you accept the gate, which
is exactly the choice StackBlitz made, and it is a reasonable one for their
threat model, not an oversight.
There is one important caveat: you have to open the editor at
ide.example.com. Loading it via the raw .pages.dev hostname reintroduces
cross-site and the gate comes back.
Mode C: one origin per port
Mode B isolates previews from the IDE, but every preview still shares one origin with every other preview, and the port is still in the path. Mode C gives each in-VM port its own origin:
https://k3f9a2xh--5173-vv.example.com/
The port moves into the hostname, so each preview gets genuine
localhost:<port> semantics, with its own cookies, its own storage and its own
CORS behaviour, and previews are isolated from each other as well as from the IDE.
Everything on the "breaks under path routing" list above stops being a problem,
and the prefix hacks come out of the codebase.
This is the model StackBlitz uses, and their URLs decode neatly:
https://vitejsvitelqrjey5b-c0kn--5173--87cf54cd.local-credentialless.webcontainer.io/
└────── instance / project id ────┘ └port┘ └session hash┘ └ COEP mode ┘ └base┘
Getting there involves three constraints that each look arbitrary until they bite.
Cloudflare Pages cannot do wildcard custom domains. They are exact hostnames only. So the wildcard origin has to be a Worker bound to a route, serving the static Service Worker runtime and the bridge document. It runs no kernel and no IDE; it is pure static hosting for an origin that has to exist per port.
The wildcard must be a prefix, so the tag must be a suffix. Cloudflare
routes only allow * at the start of a hostname. vv-*.example.com is an
infix wildcard and is rejected. That is why the marker is a suffix and the route
reads *-vv.example.com/*, which has the pleasant side effect of being narrow:
it matches Vivari preview hosts and nothing else on the zone. Anything else that
reaches the Worker is passed straight through untouched.
Free TLS covers exactly one label. Cloudflare's Universal SSL issues a
certificate for the apex plus a single-level wildcard: *.example.com matches
abc.example.com but not abc.def.example.com. So a scheme like
*.preview.example.com is two levels deep, is not covered, and produces a TLS
error rather than a helpful message. Paid Advanced Certificate Manager fixes it;
staying free means keeping preview hostnames one level under the apex and
packing the port into that single label, hence <token>--<port>-vv, all in one
label.
Each preview response is then stamped with COOP: same-origin,
COEP: credentialless and CORP: cross-origin, so the IDE (which is
require-corp) can embed the bridge iframe, and the Service Worker is allowed
to claim root scope.
The limitation nobody can engineer away
One thing worth being honest about, because it applies to every client-side container including this one.
A preview URL is not a network address. It is a capability ticket that only
works inside the browser holding a kernel-connected Service Worker. The
Service Worker is a per-origin proxy running in your browser, and its live
link to the kernel is a MessagePort held in its memory.
The consequences are observable on StackBlitz and we inherit all of them:
- Paste the URL into a new tab on the same browser and it works, because that tab is claimed by the same Service Worker, which already holds the port.
- "Open in new tab" sometimes needs a popup and a reload. That happens when the
Service Worker's in-memory port has lapsed because it was killed while idle,
and the popup provides a
window.openerchannel to re-handshake. - Open it on another machine and it fails, even with the project still open
elsewhere. The kernel lives in the first machine's tab RAM.
postMessagedoes not cross the network. - Close the editor tab and the preview dies.
None of that is fixable within the no-server model, because the thing serving the preview genuinely is not a server. A persistent, shareable preview URL requires a real backend, which is a different product decision, not a bug to file.
Choosing
| A. same-origin | B. shared preview origin | C. wildcard per port | |
|---|---|---|---|
| Extra infrastructure | none | one more Pages project | custom domain + wildcard DNS + Worker |
| Port encoded in | path | path | hostname |
| Isolates IDE from preview | no | yes | yes |
| Isolates previews from each other | no | no | yes |
| Real per-port semantics | no | no | yes |
The modes are a deploy-time choice rather than a runtime toggle, which keeps the core simple. Start at A if you are running your own trusted code and want zero infrastructure. Move to B with same-site subdomains as soon as anyone else's code runs in your previews. Go to C when previews need to be isolated from each other, or when apps genuinely need to believe they are on their own host.
The deployment guide has the full setup for each.