Preview app
build.preview emits the CMS preview app into your output tree, at the address
you name.
build: {
outDir: "./site",
preview: {
path: "/_preview",
bridge: "./src/preview-bridge.ts",
},
// ...
}That site gets a /_preview/index.html and, under /_preview/assets/,
the app's own chunks. pagedeck build says so under its summary, on one line:
preview: /_preview — this app authenticates nothing and renders any draft posted to it; put the deployment behind whatever the drafts need (Pagedeck documentation: Preview app, Security)
The app is a client-rendered build target for a CMS visual editor. It loads your whole component registry and renders whatever draft the editor posts to it, through the same render function that built your pages.
Security
Read this before you declare build.preview.
The preview app authenticates nothing. There is no login, no token and no session. Anything that can reach the URL can post a draft to it and see it rendered, and the drafts your editor sends are your unpublished content.
The framework provides no access control and is not going to. What stands in front of the app is whatever stands in front of your site: a password on the path at your CDN or reverse proxy, an allowlist, a separate bucket that is not public. If your published site is public and your drafts must not be, the app's path needs something the rest of the site does not have.
The document carries <meta name="robots" content="noindex">, which asks
crawlers that honour it not to list the address. That is not access control and
it is not offered as any.
The bridge's own defence is an origin allowlist, which decides who may post a draft, not who may load the app. The allowlist lives in your bridge module, as in the example below, so widening it is an edit to a file you review. Write it with these rules:
- Compare each origin whole, with
===or an array'sincludes. A prefix or substring test admitshttps://editor.example.attacker.testashttps://editor.example. - List exact origins, with no wildcard and no default. A bridge that accepts any origin renders whatever any page on the internet posts at it.
- Never list
"null". A sandboxed frame or afile:page posts with that origin, so listing it admits any page that can make one. - Drop a message from an unlisted origin without a report. A browser tab
receives
postMessagetraffic from other software all the time, and a report for each message fills the console. The cost is that a wrong origin looks exactly like an editor that sent nothing. When a preview never updates, check the list first.
Declared or absent
A site that declares no preview gets exactly the build it got before the
field existed. No document, no chunk, no manifest row. That is the whole
design: an unauthenticated renderer of your drafts is not something a build
ships because somebody forgot a flag, so it ships only where somebody wrote the
address down.
There is no default path, for the same reason. A default would put the app at one well-known address on every site built with this framework, and since the app authenticates nothing the address is the only thing about who finds it that you control.
Everything it emits is under one path
The document and every chunk live under the path you declared — nothing of the
app is mixed into your site's own /assets/. So the prefix you put a login in
front of is the prefix the app is at, and a diff of two output trees shows the
app arriving as one subtree.
Your pages are unaffected. The preview app and your site are two separate bundler runs sharing no module graph, so no chunk any page of yours loads holds a byte of it.
Per-tree
A site whose locales sit on domains of their own gets the whole app in each of those trees. The document loads its app with a root-relative URL, which a browser resolves against the host that served the document — so one copy in one tree would be a broken page on every other host. Point each market's editor at that market's host.
The bridge
bridge names one of your own modules, which default-exports a
PreviewBridge from @pagedeck/preview. It is an interface with one method:
interface PreviewBridge {
subscribe(onDraft: (payload: unknown) => void): () => void;
}The app calls subscribe once when it mounts. Your bridge listens to your
editor, passes each draft to onDraft, and returns a function that stops
listening. The app reads each payload as a draft and refuses one it cannot
read, naming every fault. Everything about your editor's messages is the
bridge's job, because the framework knows no editor.
A bridge for an editor that sends each draft with postMessage is a short
module. src/preview-bridge.ts:
import type { PreviewBridge } from "@pagedeck/preview";
// The exact origins your editor runs on. Nothing else may post a draft.
const EDITOR_ORIGINS: readonly string[] = ["https://editor.example"];
const bridge: PreviewBridge = {
subscribe(onDraft) {
const listener = (event: MessageEvent): void => {
if (!EDITOR_ORIGINS.includes(event.origin)) return;
onDraft(event.data);
};
window.addEventListener("message", listener);
return () => {
window.removeEventListener("message", listener);
};
},
};
export default bridge;The allowlist is in this file and not in the config, so the config key stays a module path and nothing else. If your editor also sends handshakes or pings on the same channel, drop them in the listener as well. The app reports each payload it cannot read as a draft, so one forwarded ping is one error.
bridge is optional. An app built without one renders nothing until something
sends it a draft, which is what you want if you are wiring a bridge of your
own somewhere else.
What it does not do
There is no pagedeck preview verb and no dev-server preview route. pagedeck build is
what emits the app, because the app is a deployed artifact and a deploy is what
an editor loads.
The path is not a route. No definePages() declares it, no page carries it, and
your sitemaps and feeds never mention it. If a page of yours would be published
under the same address, the build refuses the collision rather than overwrite one
file with the other, and names every key it collided at — move the page, or
declare a different path.