Viewer guide

Serving prototypes

Where a deployed prototype is served from, why some apps show their own 404 there, and the two fixes that make a build load everywhere.

Once a project has a deployment, the Viewer serves it. There are two places it can serve it from, and the choice decides whether some apps work at all.

  • Under the Viewer's own address, at /p/{slug}/. This is what you get with no DNS setup, and it is where the problems on this page happen.
  • On an address of its own, {slug}.<your domain>, once you set VIEWER_SERVE_DOMAIN. Nothing on this page goes wrong there. Where prototypes are served covers the setup and the other two modes the Viewer can fall into.

Under the Viewer's address#

Every prototype is served under /p/{slug}/. /p/acme-checkout without the trailing slash redirects to /p/acme-checkout/.

Most builds expect to sit at the root of a site, so the Viewer adjusts them on the way out: it points relative URLs at the prototype's own path, and it rewrites root-relative asset URLs (/assets/…) in the HTML and in CSS files so they stay inside that path.

What it cannot do is change URLs baked into your JavaScript. That is the source of both problems below, and the review screen shows a warning banner above the rail when a build has that shape and would not load fully for a signed-in reviewer.

A request for a path that is not a file falls back to the prototype's index.html, which is what lets an app with client-side routes open on any of them.

"May not load fully"#

That banner means the build references assets from the site root (URLs like /assets/index-abc123.js) and this deployment serves prototypes under a path, so those URLs escape it. For a prototype anyone can open, the Viewer usually recovers them; for a prototype behind sign-in it cannot, which is when the banner shows.

Two real fixes, either of which makes the banner go away on the next deployment:

Build with a relative base, so the bundle's own URLs stay inside whatever path it is served from:

  • Vite: base: './' in vite.config.ts.
  • Create React App: "homepage": "." in package.json.
  • Most other bundlers have an equivalent "public path" or "base" setting; ./ is the value you want.

Or give prototypes their own addresses, so there is no path to escape: set VIEWER_SERVE_DOMAIN. This is the better answer for a deployment with real DNS, because it removes the whole class of problem rather than fixing one build.

The app shows its own 404#

Warning An app whose router was built for / can render its own 404 page when served at /p/{slug}/, while the build reports success and nothing in the log hints at a problem.

The symptom is easy to misread: the page loads, styling is intact, and the app itself says "not found". That is your app's 404, not the Viewer's. The Viewer's own misses are plain text: "Prototype not found", "Prototype has no deployment yet", or "Not found". It happens because the router is compiled into your JavaScript, sees the path /p/acme-checkout/, and matches none of its routes.

Three ways out, in order of how much you control:

  1. Build with a relative base. In Vite, base: './' produces output that works at any path.
  2. Tell your router its base. Vue Router's createWebHistory('/p/acme-checkout/'), React Router's basename. This ties the build to one slug.
  3. Give prototypes their own addresses. The prototype then sits at the root, where a router built for / is simply correct.

On an address of its own#

Set VIEWER_SERVE_DOMAIN and each prototype gets its own hostname, {slug}.{VIEWER_SERVE_DOMAIN}, serving the prototype at /.

VIEWER_SERVE_DOMAIN=proto.example.com

Nothing is rewritten there. The prototype is at the root of its own address, so its own URLs are already correct, and a prototype on its own address cannot reach the Viewer's API or your session at all.

It needs one wildcard DNS record and one wildcard TLS certificate for *.{VIEWER_SERVE_DOMAIN}, set up once. That is the only reason it is not the default: some hosting setups cannot provide a wildcard, especially on a domain the host provides.

Only a plain slug directly under the serve domain counts as a prototype address. evil.acme.proto.example.com is not one, and neither is the bare serve domain.

For local testing you need hostnames that resolve. A wildcard resolver works without touching /etc/hosts: set VIEWER_SERVE_DOMAIN=127.0.0.1.nip.io and open a prototype at http://acme-checkout.127.0.0.1.nip.io:3100/.

What the Viewer adds and caches#

The Viewer adds one script to each page it serves, for comment pins and the inspector. Nothing is written into your repository or your build output; apart from the URL rewrites above, your build is served as it is.

Pages are never cached, and every other file is cached only in the reader's own browser, for five minutes. Do not put a shared cache in front of /p/: a cached page that one member was allowed to see would be served to someone who was not.

Turning the policy off#

Under the Viewer's own address, a policy header on every prototype response is what keeps a prototype from calling the Viewer's API as the signed-in reviewer. VIEWER_PROTOTYPE_CSP overrides it, with three states:

  • Unset (or whitespace-only): the default policy.
  • The literal off: no policy header at all.
  • Any other string: sent as the header value, as written.

Warning VIEWER_PROTOTYPE_CSP=off under the Viewer's own address removes the only thing preventing a hosted prototype from reading the Viewer's API with the reviewer's own credentials, including the endpoint that mints personal access tokens. If you need the policy gone, give prototypes their own addresses instead, where the browser's own rules do the work.

Next#