pages.deploy.json. It says
whether the site is static or has a backend, which paths that backend answers, and what it
asks to be wired to.
You never have to write it. If the file is absent we derive one from what the bundle carries
and hand it back in the ingest response, so you can see what we decided and commit it if you
agree.
What is live today: the contract is validated on every upload — a bundle is
accepted or refused against the shape below, and the manifest we resolved comes back on the
response — and the
DB and submissions bindings are wired. env.DB is real in your
Worker, your migrations are applied when a revision is staged, and a post to /__lead is
mirrored into the table you name. PROJECT_DB is the exception: it is declared and
recorded today, and its binding arrives with the project-database stage.Where the file goes
Your Worker modules follow the same rule:
_functions/ in an already-built bundle,
public/_functions/ in a source project. An already-built bundle may also put its entry at
the root (_worker.js — what the Cloudflare adapters emit); we relocate it under
_functions/ when we stage it.
Four paths are reserved, and that list is closed. These are the only objects in a staged
bundle a visitor can never fetch:
Everything else in the bundle is servable — including any other
_-prefixed directory you
ship. We do not reserve a prefix pattern; we reserve those four paths and relocate into
them, which is why your migrations move and your pages never do.
Your declared migrations get the same treatment, on both routes in. Name the directory
whatever suits your project — migrations, db/sql — and we move it to _migrations/ as
we stage the bundle. That is what makes “staged, never served” true whether you uploaded a
built bundle or handed us a source project to build.
Your own field is never rewritten. bindings.database.migrations comes back normalized —
a trailing slash is removed, so "migrations/" reads back as "migrations" — and otherwise
untouched. Where the files ended up is recorded separately, in
bindings.database.stagedMigrationsPrefix (always _migrations/), which the platform writes
after staging and reads when it applies them. Do not set it yourself — a bundle that arrives
carrying it is refused, because it would tell us to read your schema from a directory we
never staged.
Where we look for it depends on which route you took, and it is the same place the
route puts everything else. In an already-built bundle the path is relative to the bundle
root. In a source project it is relative to public/ — "migrations": "migrations"
means public/migrations/, exactly as entry means public/_functions/…. A directory
outside public/ is not part of what the toolchain copies into your build output, so it is
neither built nor served, and we do not move it.
Directories you do not declare stay exactly where you put them, including an ordinary
migrations/ page you publish on purpose.
The shape
PagesDeployManifest in
the OpenAPI document. It is generated from
the same definition the platform validates against, so it is never out of date.
What the platform decides, and will not let you set
Three things are ours, and a manifest that sets one is refused by name rather than ignored:- the script name — derived from your site id, so one site can never claim another’s name;
- the compatibility date — pinned by the platform, so the Workers runtime cannot change your site’s behaviour without a rebuild;
- the ceilings —
limitsis a request; asking for more than the ceiling is refused with the number, never quietly clamped to it.
mainModule is the old spelling of entry. A file that still uses it is accepted as the
legacy pages.functions.json; in pages.deploy.json, use entry.
A static site
The smallest honest manifest. You can also ship no manifest at all and we will synthesize exactly this.index.html at its top level. Zip the folder’s
contents, not the folder.
A Worker bundle
pages.deploy.json, _worker.js, assets/, migrations/ — and send it:
index.html at its root: your Worker answers /.
The manifest above is validated, recorded and staged, and the bindings it declares are
injected: env.DB is your database, a declared secret whose value is set on the site is
there, and form posts are mirrored into your submissions table. env.PROJECT_DB is the
one exception — declare it now if you will want it; it is bound by the project-database
stage.
Bring your own app
You do not have to build anything yourself. Send us the source project and we run its framework’s own Cloudflare build for you, then map the output onto the contract above.
In every case your static files land at the bundle root and your server code lands under
_functions/, which is never served to a visitor.
What we change, and how you see it
If your project needs a framework adapter and does not have one configured, we add it — deterministically, with no model involved — and the build result lists every file we touched:astro.config.base.mjs and the file at astro.config.mjs becomes a small wrapper that
imports it and sets exactly two things — output and adapter. Everything else you
configured — integrations, Vite plugins, site, base, image settings — passes through
untouched. Delete the wrapper and rename your file back to undo it.
For Next.js we add open-next.config.ts and wrangler.jsonc, and point your build script
at opennextjs-cloudflare build. Nothing else in your package.json changes. Your own
next build is not lost — the adapter runs it, because open-next.config.ts carries
buildCommand: "next build". That split is deliberate and load-bearing: the adapter builds
your app by running your package’s build script, so a build script that also named the
adapter would re-enter it forever.
If you had already wired the adapter yourself, we change nothing and changes comes back
empty. The same is true of a second build of a project we already conformed.
The one thing you have to do yourself
Declare the adapter as a dependency. We install from your lockfile alone and never resolve versions off the registry at build time, so we cannot add a package yourpnpm-lock.yaml does not corroborate. A project missing its adapter is refused before
anything is installed, naming exactly what to add:
A source project also needs a
pnpm-lock.yaml. A package-lock.json or a yarn.lock is
not one, and a project shipping only those is refused by name.
If both configs are present
A project carryingastro.config.* and next.config.* at its top level is refused
naming both files. We will not guess which one you meant and spend a build finding out.
A Vite app with an external backend
The Lovable and Bolt shape — a React app in the browser talking to Supabase or your own API. It ingests as an ordinary static site, your backend stays exactly where it is, and your secrets stay yours. We add one note to the build result:VITE_* variables to notice the pattern; we never read or store their values.
Your form entries in your own database — bindings.submissions
Name a table in bindings.submissions and every post to /__lead lands twice: once in
Goosy, where the leads list and the CRM sync read it, and once as a row in your table,
which nothing but your own code touches. You write no code to make that happen — and you do
create the table, because your site’s migrations own your schema, not us.
The platform writes exactly six columns, always these and only these:
Put this in your own
migrations/ directory, changing only the table name:
Telling your forms apart — form (or form_type)
One /__lead serves every form on your site, so a hidden field is what tells a newsletter
signup from an application. Post it as form; form_type is accepted as an alias, so
a site already sending that spelling needs no change. If a page sends both, form wins.
If form is present but its value is refused, we do not fall back to form_type — we
know which field your page meant, so the lead is stored with form empty rather than under a
name from the other field. An empty form is treated as absent, so the alias behind it still
answers.
_ and -, up to 64
characters. Anything else is refused to null rather than tidied into something
acceptable, because a value we rewrote would be a form name you never declared. Case and
surrounding spaces are the one exception: Contact and contact are the same form. A refused
value never costs you the lead — the submission is stored either way, with form empty.
id text primary key is required. The insert is on conflict(id) do nothing, so a
redelivered submission adds nothing and overwrites nothing — and SQLite refuses the statement
outright if id is not a primary key or unique.
A column we do not write may exist, but it has to accept a row that does not set it: a not null column with no default makes every insert fail. Add columns of your own as nullable, or
give them a default.
The mirror is best-effort and never costs you a lead. If the table is missing, has the
wrong shape, or the site has no database yet, the lead is still stored by Goosy and the form
still answers the visitor normally; the attempt is recorded against the lead with the reason.
Nothing is retried, so a fix to your schema applies to the next submission, not to past ones —
the leads API is where past leads live either way.
What the response tells you
Every successful ingest answers with the contract it resolved to:kindisprebuilt(a bundle with a manifest),bundle(a plain static folder) orsource(a project we build).deploySourceisdeclared(your file),legacy(yourpages.functions.json, translated) orsynthesized(ours).deployis the manifest as we resolved it — defaults filled in,entryrewritten to where it was actually staged (under_functions/), andbindings.database.stagedMigrationsPrefixadded when you declared migrations. Your ownbindings.database.migrationscomes back normalized (trailing slash removed) and otherwise unchanged. Read the response rather than assuming your file was taken verbatim.
When a bundle is refused
Every refusal names the field, and a ceiling refusal names the number. Nothing is written when a bundle is refused — fix the file and send it again.Related
- Pages API — creating sites, building, publishing, domains.
- Recipes — a full run from an empty directory to a live site.
- Errors and refusals — the shared error envelope.