Deploy a site
pagedeck build writes a static site, and no pagedeck command uploads it.
You upload it to your origin, the bucket or directory your host serves, with
the host's own tools. This page covers what to upload, how to upload only the
files that changed, how to put an earlier build back, what to keep between CI
runs, and how to give your host the site's redirects and headers.
The pagedeck command lists every verb, flag and exit code
used here.
The commands run in the site's directory, the one holding
pagedeck.config.ts. The paths are the defaults of the site
npm create pagedeck writes: the site goes to site/, and the content store
is content.db. If you have no site yet,
Your first site makes one.
1. Upload the output directory
Sync the content into the content store, then build:
npx pagedeck sync
npx pagedeck buildUpload everything in site/. Any host that serves a directory of files can
serve it. Do not upload content.db or .pagedeck/: the build reads them, and
a visitor has no use for them. They are outside site/ so that copying the
whole directory is safe.
A full build deletes from site/ every file the previous build wrote and this
build did not. A retracted page is gone from the directory after the next
build. A host that mirrors the directory must also delete on its side. For an
S3 bucket, aws s3 sync deletes only when you ask it to:
aws s3 sync site/ s3://my-bucket/ --deletesite/manifest.json is part of the upload. It records every file the build
wrote and the hash of each, and the next section uses it. A plain static host
also serves it to anyone who asks for /manifest.json. The edge artifacts in
section 5 answer that request with a 404.
2. Upload only what changed
A full upload is simple, but it sends every file on every deploy. To send only the files that changed, compare the manifest of the build your origin serves with the manifest of the new build:
npx pagedeck diff live-manifest.json site/manifest.json > plan.jsonlive-manifest.json is the manifest.json your last deploy uploaded. Read it
from the origin itself, not from the site's public URL, because the edge
artifacts refuse /manifest.json. For an S3 bucket:
aws s3 cp s3://my-bucket/manifest.json live-manifest.jsonOr keep the site/manifest.json of each deploy as a CI artifact, and download
the last one. The deploy recipe
reads it from a bucket with a presigned GET. On a first deploy there is no live
manifest: upload the whole of site/ as section 1 does.
pagedeck diff writes a JSON document, the plan. Your upload script reads it:
treeshas one entry per output tree. A site with no per-locale domains has one. A tree with adomainkey is the directory of that name undersite/.- Each tree's
uploadlists the files that are new or changed. A row'spathis the file's path in its tree:/about/index.htmlissite/about/index.html. Upload the rows in the order listed. The scripts, stylesheets and images come before the pages, so no page goes live before a file it loads. - Each tree's
prunelists the files the new build no longer writes. Delete them, but not before the time in the plan's top-levelprune.notBefore. A visitor who loaded a page from the old build can still ask for a script that build used. The wait is seven days after the new build was made, and--grace-secondschanges it. statscounts the files added, changed, pruned and unchanged, and the bytes to upload.
Upload site/manifest.json last, after every row. It is the live manifest for
the next deploy.
pagedeck diff refuses a build that was not made on top of the live build.
Each build records the newest build in .pagedeck/manifests when it started.
If that is not the build the origin serves, another deploy happened in
between, and this plan would overwrite it. The refusal exits with code 2. Run
pagedeck build again so the new build starts from what is live. A build with
no record at all is refused the same way, which is what happens on a CI runner
that starts with an empty .pagedeck/. Section 4 keeps it.
--force deploys the build anyway. Pass it by hand, for one deploy you have
checked. A pipeline that always passes it has turned the check off.
Deploy serialization and rollback explains
the record and how to keep two deploys from running at once.
3. Roll back
Each build keeps a copy of its manifest in .pagedeck/manifests, as
<build id>.json. To put an earlier build back, list them, and pass the name
without .json:
ls .pagedeck/manifests
npx pagedeck rollback 3c77b1de-52a0-4e77-bb0e-8f0a1c2d3e40 > rollback.jsonpagedeck rollback writes the same kind of plan as pagedeck diff. It
compares site/manifest.json, the build your origin serves, with the kept
manifest. Run it where site/ holds the live build. Your upload script runs
the plan as it runs a deploy. A rollback is always out of order, so it takes
no --force.
The plan names files, and the manifest holds no file contents. Upload the rows
from the output of the build you are restoring: keep each deployed site/ as
a CI artifact, or build that commit again.
The build keeps the 20 newest manifests, and build.retention.keep changes
that number.
Deploy serialization and rollback
covers how far back a rollback reaches.
4. Keep the content store and .pagedeck/ between CI runs
A CI runner starts with no content.db and no .pagedeck/. Without
content.db, every run syncs all the content from the source. Without
.pagedeck/, every build records no earlier build, and pagedeck diff
refuses every deploy.
Keep the content store as a snapshot. pagedeck store pull downloads
content.db and pagedeck store push uploads it. Give the target in
PAGEDECK_SNAPSHOT_URL and none on the command line, as
PAGEDECK_SNAPSHOT_URL explains:
- run: npx pagedeck store pull
env:
PAGEDECK_SNAPSHOT_URL: ${{ secrets.SNAPSHOT_URL }}
- run: npx pagedeck sync --incremental
- run: npx pagedeck build
- run: npx pagedeck store push
env:
PAGEDECK_SNAPSHOT_URL: ${{ secrets.SNAPSHOT_URL }}
On the first run there is no snapshot to pull, and no cursor for
pagedeck sync --incremental to start from. Run pagedeck sync and
pagedeck store push once to create both.
Keep .pagedeck/ in your CI's cache. Restore it before the build and save
it after. It holds the kept manifests, so it is also what pagedeck rollback
reads.
pagedeck build --incremental renders only the pages the content changes
touch, and reads the rest from the previous site/. On CI it needs site/
from the last run too, from the same run as the snapshot. A full build does
not.
5. Give your host the redirects and headers
build.routing declares the site's redirects, its 404 page and the response
headers for each path prefix. Routing covers the fields,
and Security headers the headers to set
before a deploy. The build compiles them into one host-agnostic routing
document first, writing it into site/manifest.json in a form that names no
host.
An adapter compiles that document into edge artifacts, the files one host
reads. Each host has its own adapter package. Install the one for your host,
here Netlify, and name it in build.adapter:
npm install @pagedeck/adapter-netlifyimport { defineConfig } from "@pagedeck/core";
import { netlify } from "@pagedeck/adapter-netlify";
export default defineConfig({
collections: [],
build: {
pages: [],
components: {},
adapter: netlify(),
},
});A new site can skip this step: npm create pagedeck@latest my-site --host netlify
installs the adapter and writes both of these for a fresh site, and
--host vercel or --host cloudflare-pages do the same for those hosts. The
above is what to do by hand, or to a site that already exists.
pagedeck build runs the adapter after it plans the routing document and
before it reports success. Each edge artifact has a role, and the role says
where the build writes it: a tree-file into site/<tree>/, where it rides
site/manifest.json and pagedeck diff like any other file, and every other
artifact into edge/<tree>/, beside site/, which you install on the host
yourself (<tree> is empty for a site with one output tree). An incremental
build writes the same files a full build does, and an artifact an earlier
build wrote that this one does not is removed from wherever it was written,
the way a full build removes a retracted page from site/. pagedeck dev
runs no adapter: it renders pages from the store on request and writes
neither site/ nor edge/.
For another host, install its adapter and name its factory in build.adapter
in place of netlify():
netlify()from@pagedeck/adapter-netlifywrites_redirects, and_headerswhen the site declares header rules. Both are tree files, sopagedeck diffuploads them with the rest of the site, after the site's own files, so no redirect goes live before its target.nginx()from@pagedeck/adapter-nginxwritesrouting.confintoedge/.includeit in theserverblock that servessite/.cloudfront()from@pagedeck/adapter-cloudfrontwrites a viewer-request and a viewer-response CloudFront Function, and configuration fragments when the routing needs them, intoedge/. Publish each function and associate it with the distribution. A function left inedge/does nothing until you publish it.cloudflareWorker()from@pagedeck/adapter-cloudflare-workerwritesworker.jsintoedge/, published as a Worker with the site's R2 bucket bound to it. This adapter has not yet served a site in production: test it on a staging deploy before you rely on it.cloudflarePages()from@pagedeck/adapter-cloudflare-pageswrites_redirects, and_headerswhen the site declares header rules, the same as Netlify's. Both are tree files. It refusestrailingSlash: "never"; see "Cloudflare Pages" below for its build settings.vercel()from@pagedeck/adapter-vercelwritesvercel.json, a tree file; see "Vercel" below for its build settings and where the file must end up.
Outside pagedeck build — against a manifest from another build, or to try an
adapter without building — call it directly over a routing document:
import { readFileSync } from "node:fs";
import { readManifest } from "@pagedeck/core";
import { netlify } from "@pagedeck/adapter-netlify";
const manifest = readManifest(readFileSync("site/manifest.json", "utf8"), "site/manifest.json");
for (const artifact of netlify().compile(manifest.routing).artifacts) {
console.log(artifact.role, artifact.path);
}Every adapter's edge artifacts answer /manifest.json and /.pagedeck/ with a
404, even for a site that declares no routing. Install them, and the manifest
you uploaded to the origin stays private.
Cloudflare Pages
Cloudflare Pages builds from a
connected git repository, or takes a build you upload yourself with
wrangler (below). Set these in the project's build settings:
| Setting | Value |
|---|---|
| Build command | npx pagedeck sync && npx pagedeck build |
| Build output directory | site |
NODE_VERSION (environment variable) |
a version meeting @pagedeck/core's engines.node, 22.18.0 or later |
Cloudflare's build image otherwise runs an older Node that cannot load
pagedeck.config.ts, so NODE_VERSION has to be set explicitly; it is not
read from a file in the repository.
Install @pagedeck/adapter-cloudflare-pages and name its factory in
build.adapter: adapter: cloudflarePages(), imported with
import { cloudflarePages } from "@pagedeck/adapter-cloudflare-pages". It
refuses trailingSlash: "never" — Cloudflare Pages redirects a directory's
index.html to its slashed address on its own, which the adapter cannot
override — with the fix set trailingSlash: "always", the default since
pagedeck 0.1.
Direct upload with wrangler
To publish a build without connecting a git repository, build it and upload the output directory yourself:
npx pagedeck sync
npx pagedeck build
npx wrangler pages deploy siteFull builds, or keeping the content store
Cloudflare Pages is a directory-sync host: it reads no manifest and
republishes whatever site/ holds, so the raced-deploy refusal "Upload only
what changed" above describes never applies to a Pages build. A build that
starts with no content.db syncs every entry, so a plain npx pagedeck sync && npx pagedeck build on every push, as the build command above runs, is the
normal case for this host.
If a full sync is too slow for your content source, keep content.db between
builds the way "Keep the content store and .pagedeck/ between CI runs" above
describes. PAGEDECK_SNAPSHOT_URL is a credential (see
PAGEDECK_SNAPSHOT_URL), and any build
that has it can overwrite the snapshot production builds start from. In the
project's Settings > Variables and Secrets, add it to the Production
environment only, and select Encrypt so it is stored as a secret.
Cloudflare's bindings
page says variables are set "for both your production and preview environments
at runtime and build-time", but it documents Encrypt for secrets bound to
Pages Functions and does not say whether an encrypted value reaches the build.
If the first production build stops with the pagedeck store pull usage error
that names PAGEDECK_SNAPSHOT_URL, the build did not receive it.
Then run the snapshot steps only on a production build. Cloudflare sets
CF_PAGES_BRANCH to the name of the branch being deployed
(build configuration),
and its guide to
build commands per branch
branches on it in a build.sh the same way. Commit this script beside
pagedeck.config.ts and set the build command to sh build.sh:
build.sh:
set -e
if [ "$CF_PAGES_BRANCH" = "main" ]; then
npx pagedeck store pull
npx pagedeck sync --incremental
npx pagedeck build
npx pagedeck store push
else
npx pagedeck sync
npx pagedeck build
fiReplace "main" with the production branch set in the project. If that branch
is ever renamed and the script is not, production builds take the else
branch and silently stop pulling and pushing the snapshot.
A preview build gets no PAGEDECK_SNAPSHOT_URL, so it neither reads nor
writes the snapshot: it syncs every entry from the content source and builds
the whole site. It cannot run the production steps instead: pagedeck store pull with no URL stops with a usage error that names PAGEDECK_SNAPSHOT_URL,
and pagedeck sync --incremental on an empty store stops with "no cursor to
sync since — run a full sync first".
On the first production run there is no snapshot to pull and no cursor for
--incremental to start from: run pagedeck sync and pagedeck store push
once first, the same as that section describes for any CI runner.
Vercel
Vercel builds from a connected git repository. Set these in the project's Build and Deployment settings (https://vercel.com/docs/project-configuration/general-settings):
| Setting | Value |
|---|---|
| Framework Preset | Other |
| Build Command | npx pagedeck sync && npx pagedeck build |
| Output Directory | site |
| Node.js Version | 22.x or 24.x, not 20.x, which predates @pagedeck/core's engines.node floor |
Install @pagedeck/adapter-vercel and name its factory in build.adapter:
adapter: vercel(), imported with
import { vercel } from "@pagedeck/adapter-vercel".
vercel() writes vercel.json at the root of each output tree —
site/vercel.json for a site with one tree — a tree file, so pagedeck diff
uploads it with the rest of the site like any other. Vercel's own docs
describe vercel.json as living at the project's root, which for a pagedeck
site is where pagedeck.config.ts is, not where the build writes site/;
setting Output Directory to site (above) is what makes Vercel read the one
pagedeck build wrote instead. Check this on a staging deploy before relying
on it, by requesting a path the routing document redirects and reading the
response.
Full builds, or keeping the content store
Vercel's build runs in a fresh container on every deploy: it holds no
content.db and no .pagedeck/ from any earlier build. A build that starts
with no content.db syncs every entry, so a plain
npx pagedeck sync && npx pagedeck build, as the Build Command above runs, is
the normal case for this host, and it is always a full build —
pagedeck build --incremental needs site/ from the previous run too (see
"Keep the content store and .pagedeck/ between CI runs" above), which
Vercel's build container does not carry over.
If a full sync is too slow for your content source, keep content.db between
builds with npx pagedeck store pull and npx pagedeck store push.
PAGEDECK_SNAPSHOT_URL is a credential (see
PAGEDECK_SNAPSHOT_URL), and any build
that has it can overwrite the snapshot production builds start from. In the
project's Environment Variables settings, add it with the type Secret,
which Vercel describes as "write-only after saving" (it replaced the type
Vercel called Sensitive), and with Production as its only target environment
(Config and Secret environment variables).
Then run the snapshot steps only on a production build. Vercel sets
VERCEL_ENV to "the environment that the app is deployed and running on",
one of production, preview or development, at build time
(system environment variables).
Commit this script beside pagedeck.config.ts and set the Build Command to
sh build.sh:
build.sh:
set -e
if [ "$VERCEL_ENV" = "production" ]; then
npx pagedeck store pull
npx pagedeck sync --incremental
npx pagedeck build
npx pagedeck store push
else
npx pagedeck sync
npx pagedeck build
fiThe build itself stays a full build — site/ is not carried over — but a
production sync no longer re-fetches every entry from the content source. A
preview build gets no PAGEDECK_SNAPSHOT_URL and takes the else branch,
which syncs every entry and touches no snapshot, as on Cloudflare Pages above.
On the first production run there is no snapshot to pull and no cursor for
--incremental to start from: run pagedeck sync and pagedeck store push
once first, the same as that section describes for any CI runner.
Netlify
A netlify.toml at the repository root, beside pagedeck.config.ts, builds
and publishes the site on every push:
netlify.toml:
[build]
command = """
npx pagedeck sync &&
npx pagedeck build
"""
publish = "site"
[build.environment]
NODE_VERSION = "22.18.0"
[build.processing.html]
pretty_urls = falsepublish = "site" is the directory pagedeck build writes. netlify()'s
_redirects and _headers are tree files, so they are already inside it by
the time the build command exits; Netlify uploads them with the rest of the
site, no separate step. NODE_VERSION must meet the adapter's own
engines.node, ^22.18.0 || >=23.7.0.
Pretty URLs is a
Netlify post-processing option that rewrites /about to /about/ on its own,
ahead of the site's own trailingSlash policy and the rows netlify()
compiles for it. Set pretty_urls = false so Netlify does not move a page
to its other spelling against the site's policy.
Netlify's
redirect options
say: "Our CDN edge nodes do URL normalization before the redirect rules kick
in", so "Netlify will match paths to rules regardless of whether or not they
contain a trailing slash", and "you cannot use a redirect rule to add or
remove a trailing slash". So netlify() writes no row from a redirect
target's other spelling to the target. Other hosts get that row. On Netlify it
would redirect the target to itself. For the same reason netlify() refuses a
configured redirect whose two paths differ only by a trailing slash. A
redirect source still answers both spellings with its redirect. What Netlify
serves at a page's other spelling has not been checked on a live deploy.
Each tree's 404 page is written at the tree's root as 404.html too (see
the 404 page), and
Netlify picks it up
for any path _redirects does not already answer with its own 404 row.
A Netlify build starts from a clean checkout: nothing survives between builds
unless a build plugin
restores it first, so command above runs a full pagedeck sync, not
--incremental, on every build. Keeping content.db and .pagedeck/ between
builds, to sync and build incrementally instead, takes the same two pieces
this page's section on CI already covers — a snapshot for the store, a cache
for .pagedeck/ — wired into that plugin or into command here, rather than
into a plain CI job's own steps.
If you keep the snapshot, set PAGEDECK_SNAPSHOT_URL in the site's
environment variables with Contains secret values selected, and give it a
value only in the Production deploy context. Netlify says "Secret values must
be set to explicit deploy contexts and scopes to avoid unexpected exposure"
(secrets controller).
Run pagedeck store pull and pagedeck store push only when CONTEXT, the
"name of the build's deploy context", is production; the other values are
deploy-preview, branch-deploy and dev
(environment variables).
Commit this script beside pagedeck.config.ts, and in netlify.toml above
set command = "sh build.sh" in place of the two pagedeck lines:
build.sh:
set -e
if [ "$CONTEXT" = "production" ]; then
npx pagedeck store pull
npx pagedeck sync --incremental
npx pagedeck build
npx pagedeck store push
else
npx pagedeck sync
npx pagedeck build
fiDeploy previews and branch deploys get no PAGEDECK_SNAPSHOT_URL and take
the else branch: a full sync and build that touches no snapshot.