Skip to main content
You do not have to build a site inside Goosy. Send us a project you already have — a plain static folder, an Astro project, a Next.js project, or a Vite app with its own backend — and we build and host it for you. This page is what to prepare before you send it, and what happens once you do.
This page describes the API path: sending a project with a key, the way a developer or a coding agent would. See Pages API for the full call reference and The deploy contract for every field a project can declare.

What the archive should contain

Send a real project — package.json, your source files, and your lockfile — zipped from the project root. A few things to leave out:
  • Build output and dependency folders. node_modules, .next, dist, and any local emulator state don’t belong in the archive — they’re rebuilt for you, and including them only makes the upload slower and harder to read.
  • Your own copies and version-control files. Leave out .git and any backup or “original” copies of the project sitting alongside it.
  • Content. Your database rows and media files are not part of the code archive — see Media and Migrations for a new site below for how those travel separately.

Dependencies: no install scripts, keep the lockfile

Two things make a project buildable here, and both are worth checking before you send it:
  • No install scripts. Dependencies are installed with lifecycle scripts turned off, so a package that only finishes setting itself up through a postinstall step won’t finish setting itself up. Most everyday packages — UI libraries, routing, forms, styling — need nothing like this. If a dependency in your project does, it needs to come out before you send us the project.
  • Commit your pnpm-lock.yaml. We build with pnpm, and we install exactly what that lockfile lists — we never resolve a version off the package registry on your behalf. Keep it committed and current: run pnpm install locally after any dependency change, so the lockfile reflects what package.json asks for. We read only pnpm-lock.yaml; an npm, yarn or bun lockfile isn’t read at all, so a project that ships one of those instead — or no lockfile — is refused before anything is built, naming exactly what’s missing.
We support a defined, growing set of everyday packages for the frameworks we build — routing, UI primitives, forms, styling, and your database client among them. If your project needs something we don’t support yet, checking your project first (see the sequence below) names it for you, so you know before you spend a build finding out. Until you switch something on, we add nothing to a site you send us. No tracking script, no cookie banner, nothing written to the visitor’s browser: your site is built and served exactly as it arrived. If your site runs its own analytics (PostHog, Google Analytics, anything else), keep it. Make sure the package is in your project’s own dependencies and lockfile like any other library; see Dependencies above. When you want our consent banner, your connected ad pixels or conversions reported to your ad platforms, switch them on in your site’s settings. See The consent banner, ad pixels and conversions below for what that adds and how your own code works with it.

The deploy manifest

Every project we host is read through one file, pages.deploy.json — see The deploy contract for the complete field reference. It says which paths your backend answers, and what it needs to be wired to: a database, your own storage, secrets by name. You don’t have to write it. If your project doesn’t include one, we look at what you sent and write one for you, and hand it back on import so you can see what we decided and commit it if you agree. One part of it is worth knowing before you write your own: a route can be marked so that only someone signed in to your workspace can reach it — the same way /admin is commonly protected — and we enforce that before your code ever runs. See Members-only routes.

Migrations for a new site

If your project needs its own database, declare a directory of numbered SQL files in the manifest — 0000_create_table.sql, 0001_add_column.sql, and so on — and they run in that order the first time your project is built. Migrations are tracked one file at a time, not one statement at a time: if a file fails partway through, the whole file is offered again on the next build — so write every statement so it’s safe to run again from the top. create table if not exists is the simple version of that. insert or ignore only gives you the same safety when the id column itself is declared primary key or unique — a stable value alone isn’t enough, because without that declaration there’s nothing for or ignore to conflict on. Give every seeded row an explicit id of its own (never one your database assigns automatically) and declare that column primary key or unique, so a retried file skips a row it already inserted instead of adding it twice. Keep each file under 256 KB, aim for 64 files or fewer (past that you get a note, never a refusal), and keep any single statement well under 100,000 bytes — batch a large seed across rows rather than one giant statement. The full list of what we check, and the number beside what you sent, is in the deploy contract’s ceilings. If you’re replacing a site you already run with us, an already-applied file has to match, byte for byte, what’s already been applied there — add new changes as new numbered files; don’t edit one that already ran. A new file you add runs against your site’s real, existing database as soon as your replacement is confirmed and built — before you ever publish (see Replacing a site you already run with us below), so review a schema change the same way you’d review any change to a live database.

Media

There are two kinds of media, and they travel differently:
  • Files your pages serve directly — images and assets your code references by path — ship inside the archive’s static folder. They’re classified as ordinary site files and go live with your first publish.
  • Files your backend reads from your own storage are different. Your storage is created the first time a build that declares it is staged — before you publish — and it starts out empty. There’s no bulk import for existing media today. For a brand-new site, the honest path is to add files through your site’s own upload flow once it’s live, or ask us about a one-time bulk load.

Check it, then ship it

Start with the same first step either way, then the path splits depending on whether this site has a code project already. Validate first, always. Send your archive to be checked without creating anything — every problem is named in one pass, rather than one at a time across repeated attempts. How big an archive can be depends on how you send it. Uploaded in the app, an archive may be up to 40 MB. Posted straight to the API (POST /api/pages/ingest/validate or POST /api/pages/ingest), it must stay under about 4.5 MB — our API host refuses a larger request body with a bare 413 before it reaches us, and that endpoint only takes the bytes directly — it has no field for a URL to fetch. For a larger archive without posting the bytes yourself, upload it in the app (up to 40 MB), or call the page.ingest_bundle tool with a public https:// link (see Import from an address). See what you upload.

Bringing a new site

With no existing code project for this site, sending your archive creates the site and starts the build immediately — there’s no diff to review and nothing to confirm, because nothing is being replaced.
  1. Import. Creates the site and starts the build.
  2. Preview. See below — preview is live-bound, not read-only.
  3. Publish. Moves your live address to the build you just previewed — one exact version, reviewed before anything changes.

Replacing a site you already run with us

Sending an archive to a site that already has a code project never overwrites it outright — you see exactly what would change first, and nothing is written until you say so.
  1. Import — see the diff first. The response tells you nothing was written, and carries a handle together with a diff of what would change if you went ahead:
    The diff names only code-project files — never your storage, your media, your domain, or anything else that makes the site yours. Your database is the one exception, and the rule is exact: a replacement whose migrations are byte-identical to what’s already applied changes no data; a replacement that carries new migration files applies them to your live database at that same build, before you ever publish — see Migrations for a new site above.
  2. Confirm — resend the identical archive, with the handle on the URL. Send the exact same bytes to the exact same endpoint, adding the handle from step 1 as a query parameter:
    Same file, same siteId — only the query string changes. That call is the one that actually writes: it answers with an "outcome": "ingested" body and starts the build.
  3. New migrations run against your real database, at that same build — before you ever publish. If your replacement adds new migration files, this is where they run; see Migrations for a new site above for what makes a migration safe to send this way.
  4. Preview. See below.
  5. Publish. Moves your live address to the build you just previewed.

Preview is live-bound, not read-only

Your preview link is not a snapshot. The moment your project finishes its build, its database and storage are already the same ones your published site uses — there’s no separate copy — so a preview and a live site share one backend from the start. That makes preview a faithful check before you publish, but it also means a write you make while previewing is a real write: don’t submit a form, upload media, or run an admin action against a preview of a site you don’t want to change yet.

Republish to roll back

Every build you’ve shipped stays available, and putting a previous one back live is the same act as publishing — you’re just choosing an earlier build. Rolling back reaches your 100 most recent builds; an older one isn’t offered as a rollback target. Three settings in your site’s settings, under Tracking, or have Goosy change them:
  • Consent banner. Our banner becomes the one consent source on your site, and your own code can read its decision.
  • Ad pixels. The pixels of the ad accounts you’ve connected load on your site. They need the banner on.
  • Ask before tracking. On (the default), nothing that needs consent loads until a visitor accepts. Off, analytics and your ad pixels load when a visitor arrives, and the banner is a notice that lets them opt out. Either way, a browser that sends Global Privacy Control is treated as opted out, and so is a visitor who opted out before.
We add them when your pages are served: your code isn’t changed, and you don’t need to re-import or rebuild to switch one on or off. Changes reach your live site within a minute.

What your HTML needs

  • Every page has a real <head> element. We add two script tags, <script src="/__goosy/collector.v1.js"> then <script src="/__platform.js">, as its first children; a page without a <head> tag gets nothing.
  • No second cookie banner. With ours on, remove yours.
  • If you send a Content Security Policy, it has to allow our script and the hosts of the ad pixels you’ve switched on. Our scripts are served from your own site, so script-src 'self' covers them. CSPs that use 'strict-dynamic' aren’t supported yet.
  • window.goosy and the rs-page-consent storage key are ours. Don’t define the first, and don’t read or write the second.

Reading the visitor’s decision

window.goosy exists only while our banner is on for your site. Don’t start anything that needs consent unless the state is "granted". Here’s PostHog gated on it:

Reporting a lead your server sends us as a conversion

When your server forwards a lead with your site key (see From your Worker, with your site key), it can carry the visitor’s consent, so the lead is reported to your connected ad platforms as a conversion. Three steps:
  1. In the browser, before you call your server, take window.goosy.leadFields(). It returns { consent, eventId, fbp, fbc, fields }. eventId is a fresh id when the visitor has granted consent, and null otherwise. fields holds the same values under the names our intake reads (rs_meta_event_id, rs_meta_fbp, rs_meta_fbc, rs_page_path, and rs_attribution for the visitor’s first ad click and campaign tags).
  2. On your server, add fields to the lead’s own fields unchanged, and send your page’s hostname beside them.
  3. Back in the browser, once your server says the lead was accepted, call window.goosy.trackLead(eventId) with the eventId our intake answered. That fires the browser Lead on each connected platform with the same id as our server-side event, so each platform counts the conversion once. It returns true when it fired.
  • We answer an eventId only when the lead becomes a conversion: the visitor granted consent, the form is one your site counts as a conversion (every form, until you name some in site settings), and the address isn’t a test address. Otherwise it is null, and trackLead fires nothing.
  • A value in one of those fields that isn’t in the shape leadFields() produces is refused with 400 {"error":"invalid_field","fields":[…]}, and nothing is stored. Pass them through unchanged.
  • The consent is your visitor’s claim, as on any web form. We treat a lead without an eventId as not consented and report nothing for it.
We never read, change or send anything you haven’t passed us. We don’t touch your forms, your submit handlers, your cookies, your storage (apart from the consent key above and, once a visitor’s consent is granted, the ad platforms’ own cookies), your Content Security Policy, your members-only pages, your API routes or your preview links.

Where to go next