It is for whoever will run the site: an operator (an agency or fractional CMO) setting it up for a client, or the in-house web or marketing person who will manage it. The steps are the same; who owns which account differs, and that is the second section. It stays at step level, with the handful of commands the kit exposes; anything deeper is in the kit's own docs, which ship with every clone.
The Saprel Site Kit is open source: the build method, quality gates, and operating runbooks, which anyone can run standalone. Saprel Web is what the platform adds on top when your analytics data lands: reporting, monitoring, and self-service editing. This guide covers the kit end to end and marks the few steps where Saprel Web is involved.
Before you start
Accounts. A GitHub account with an organization to hold the repository. A Cloudflare account. A Google account with access to the business's Analytics, Search Console, and Ads, or the standing to create them.
Tools. Node 20 or later. Wrangler, Cloudflare's command-line tool, installed globally; it is deliberately not a dependency of the kit. A coding agent that reads the repository's instructions file before it edits anything; the kit is written for one.
Secrets. The kit does not integrate with or require a specific secrets manager. Kit scripts receive secrets only through environment variables or stdin; they never read a secrets file and never take a secret as a command argument. Each site's secret names are listed in its site configuration (Forms section); the values live only where they are used: the site's secrets are Cloudflare Worker secrets, and anything a scheduled job reads lives in the secret store of wherever that job runs.
For operator commands that need credentials, such as a Cloudflare API token during provisioning, put an environment-variable injector in front of the command:
# 1Password Environments: maintainer-tested
op run --environment <id> -- <command>
# Bitwarden Secrets Manager: compatible, untested
bws run --project-id <id> -- <command>
# Doppler: compatible, untested
doppler run -- <command>
Maintainer-tested means the kit's maintainers run it every day. Compatible means it meets the contract above and the maintainers have not run it.
Whichever you use: the injector's own access token is already in your session, never typed on a command line. Keep one standing environment per client for operator work, and a separate short-lived one for provisioning tokens that you revoke at launch. Never print the environment, and do not rely on output masking: op run masks the values it injected, other injectors may not.
Inputs. A brand strategy with a visual-identity section (from Saprel Guide, or your own equivalent); the kit ships an outline if you are writing one yourself. Font files, or the family names so you can fetch them. The logo in SVG. A page list: which pages the site needs and what each one is for. A site without a strategy is a site that gets rebuilt.
Who owns what
The rule is short: the business owns the source, the content, its domain and the account its DNS lives in; whoever operates the site works through delegated access and pays for its own tooling; anything a platform holds is the minimum it needs and is revocable by the business. Owning is not managing: the operator still does the work. The test for each row is what happens if the operator disappears tomorrow. In-house, most rows collapse, but the domain and the Google properties belong to the business, not to a person.
| Element | Operator for a client | In-house |
|---|---|---|
| Site repository | Operator's GitHub org, private; the client is added as an outside collaborator on request; transfers to the client at engagement end (a repository transfer is quick and keeps history; re-link the deploy connection afterwards) | The business's own org |
| Cloudflare zone | The client's own account by default, with the operator as a standing Administrator. Never Super Administrator: billing, members and account recovery stay with the client, so they are never locked out. DNS carries their email and their domain verification, which puts it in the same class as the domain itself. The operator's account only when the client cannot or will not hold one, and then with the client added as a member on their domain and a written line that the zone is theirs and moves on request. Moving later is possible but it is a rebuild, not a transfer: everything but the DNS records is recreated. Never per-task access requests | The business's account |
| Domain | Registered in the client's name; the operator edits DNS through the zone and never holds the registrar login | The business's registrar account |
| GA4 property | Client is Administrator; operator is Editor | Business is Administrator; whoever runs the site is Editor |
| Search Console | Client and operator are both Owners (Owner is needed to configure the export) | Business account as Owner |
| Google Ads | Client's account; operator manages through a manager-account link; ad spend is the client's | The business's account |
| Saprel Web subscription | Per site; paid by the client, or by the operator on the client's behalf; the landed data is the client's regardless | Per site; the business pays |
If Saprel lands your analytics data, each source gets one named Saprel service account, added by the property's Administrator and removable in one row. That is the only access the platform holds.
Create the repo from the kit
- Create a new private repository from the kit. Use it as a GitHub template, or clone it at a pinned version and start a fresh history with an "Instantiate site kit" first commit. Pinning matters: the kit moves, and a site should upgrade deliberately, not by accident.
- Name it for the site and open your agent in it from the first step; the session that instantiates is the one that provisions.
- Enable the hooks once per clone:
git config core.hooksPath .githooks
The hooks enforce the commit conventions; the same rules run on pull requests. The docs/ folder stays committed: a gitignored spec layer is a single-machine spec.
Instantiate
Work through the kit's quick start, in this order.
- Brand find-and-replace. Replace the placeholder brand name and domain across
public/anddocs/. Grep afterwards; a placeholder that survives will ship. - Design tokens. Re-skin the custom properties at the top of the stylesheet from the visual-identity section of the brand strategy: the colour tokens, the two type families, spacing and radius if the strategy specifies them. Keep the token names; change the values. This is where the look of the site is decided.
- Fonts. Self-hosted only. Subset each weight you use to latin and latin-extended, drop the woff2 files into the fonts folder, uncomment the matching font-face blocks, and preload the two weights that render above the fold. No CDN font link, not even "for now".
- Site configuration. Fill the Global Settings section of the site configuration document: navigation, the CTA table (this is the single source of CTA copy), tracking mode, image conventions, forms, and indexing rules. Then the page list, one spec block per page, each with a status.
- Voice rules. Copy the brand strategy's voice rules into the slot in the agent instructions file, keeping them short and checkable by an agent without judgment calls. Mirror the CTA table there too.
Gate: the repository has no placeholder text, the tokens and fonts are the client's, and the site configuration describes the real site.
Deploy the preview
- Log wrangler in to the Cloudflare account that will host the site, and pin the account id in
wrangler.tomlso a multi-account user can never deploy into the wrong account. Set the worker name to match the repository. - Deploy:
wrangler deploy
The site comes up on a workers.dev address: the preview URL, where everything is reviewed before launch. The kit's _headers file carries a staging noindex that stays on until launch day.
- Connect Cloudflare's Workers Builds to the repository so every push to the main branch deploys, and enable non-production branch builds so each branch gets its own preview address. Spot-check that a branch preview also carries the noindex header.
npm run devserves the site locally; the screenshot script expects it there. Oncewrangler.tomlhas a route, local requests are served under that hostname and the worker's localhost Turnstile skip no longer fires: copy.dev.vars.exampleto.dev.varsand use Cloudflare's test site key in the page while developing. That file holds only Cloudflare's public test keys, never a real secret.
Gate: the preview is reachable and answers with the noindex header.
Tracking
The kit's default is Cloudflare Zaraz, tag management that runs at the edge rather than as a script in the page. No analytics snippet ships in the HTML; the site's JavaScript calls Zaraz when it is present and falls back to a direct Google tag only where a client constraint forces that mode.
- GA4 property. Use the business's existing property or create one, owned by the business as Administrator, one Analytics account per business. Add a web data stream for the domain and note its measurement id.
- Zaraz. In the zone's tag management, add the Google Analytics tool with that measurement id. Set the bot threshold to block automated traffic only, so crawlers still get the page but stay out of the analytics. Skip GA4's own "test installation" button; it looks for a script that is not there by design.
- Consent, by market. Answer one question: are this site's visitors mainly in the EU or UK? If not: Google Analytics through Zaraz with no banner; Zaraz's consent management and Google's consent-mode setting both stay off (consent mode on with no banner to update it sends every hit as denied and hollows out the data). The tool sets two first-party cookies for every visitor, and the privacy page says so. If so: either cookieless analytics in the vendor's anonymous configuration with no banner, or a real consent banner whose choice updates consent mode; Google Analytics with no banner is not an option there, however it is loaded. The settings for the cookieless branch, and the reasoning, are in
docs/decisions/0005. Either way the form sets no cookie. Record which posture the site uses in the search plan's CDN-settings table. - Events. The kit fires four standard events: form submit, outbound click, phone click, download click. Mark the ones that matter as key events in GA4: create each by name under Admin → Key events the day it ships; GA4's events list lags real-time by up to a day, so do not wait for it. Register
form_typeandofferas event-scoped custom dimensions. Never registersubmission_id: it is unique per event, and it belongs in the BigQuery export, where the join to the stored submission happens. Do not invent new event names per site; extend the properties instead.
Gate: a visit from a real browser appears in GA4's real-time view, and a fresh private window shows no banner, no storage keys, and no cookie other than the two first-party cookies Zaraz's Google Analytics tool sets for every visitor (docs/decisions/0004).
Data sources
Analytics exports are forward-only. Every day before the export exists is history nobody will ever have, so this step happens the day the properties exist, not at launch.
- Search Console. Verify the domain as a domain property, by DNS record, with the site's operator as a verified Owner. Owner, not Full, because only an Owner can configure the export. Then switch on the bulk data export to BigQuery. If Saprel lands the data, the destination is Saprel's project and you add Saprel's service account as a Full user; standalone, the destination is a project of your own.
- GA4 to BigQuery. For a site on the Saprel platform, the property's Administrator adds Saprel's analytics service account as Editor, and Saprel creates the daily export link; the operator's job is the Editor rows, nothing more. Standalone, create the link from the property's admin settings to your own project. Daily export either way; the first table lands a day or two later.
- Google Ads, if the business runs it. For a platform site, the account's Admin adds Saprel's service account as Read only on the client's own account, not on any manager account. Standalone, set up the Ads transfer in your own project.
- Existing sites. Search Console serves a sixteen-month trailing window that shrinks daily. Pull the history the same day the export is configured.
Gate: the next day, the Search Console export tables exist, and the GA4 dataset shows its first daily table within two days.
Forms
The kit's worker handles the lead form's POST and the small token request that goes with it, and passes everything else through to the static site. wrangler.toml routes /api/* to the worker first; leave that line in (Troubleshooting says what happens without it). Every integration is opt-in; a missing setting skips that step.
- Turnstile. Create a Turnstile widget for the domain in Cloudflare, one widget per site. A widget is bound to its hostnames: add the preview host too if the form will be tested before launch. Put the real site key in the form markup and the real secret on the worker: a mismatch fails every real submission, and Cloudflare's test keys on both sides pass every bot. The launch check refuses a test key on the form's page, which it reads from the site configuration's forms table. Give the worker the secret through stdin, never on the command line:
wrangler secret put TURNSTILE_SECRET
- Lead notification. Set the worker's webhook setting to a Slack or Google Chat incoming webhook, or whichever destination the business reads. A submission posts a compact summary there. The webhook URL is a secret: hidden prompt only, never in a file or a chat. A Google Chat incoming webhook can only be created by a member of that space's Workspace; if the client's space is not yours to configure, post to a space you own until the client creates theirs, then swap the secret and delete the old webhook.
- CRM handoff. If the business's CRM or marketing platform exposes a form-handler endpoint, set the worker's form-handler setting to it and the fields forward as a form post. The worker redirects to the thank-you page whether or not downstream succeeds, so a failure never loses the lead.
- Lead screen. Set
SITE_DOMAIN(a plain var inwrangler.toml) to the site's domain, and give the worker a signing secret for its time-to-submit check. Generate it and pipe it straight in, so it never appears on screen:
openssl rand -hex 32 | tr -d '\n' | wrangler secret put FORM_SIGNING_SECRET
A submission that reads as spam (too fast, throwaway address, a sales pitch) is never dropped. On a platform site it is stored and left out of the notification destination; on a standalone site it is posted there marked as possible spam.
- Platform sites (optional; a site on the Saprel platform). Set
INTAKE_URLas a var. The intake key is issued here and registered there: runbash scripts/issue-intake-key.shwith the platform operator live at the same moment. It prints the key's hash and waits; the hash is safe to relay in a chat; they register it; you press Enter and the key goes to the worker asINTAKE_KEY. The key itself is never shown. Then, after the site is deployed, set the test token and send one test lead from the same shell, so the token exists nowhere but the worker:
T=$(openssl rand -hex 32)
printf %s "$T" | wrangler secret put TEST_LEAD_TOKEN
sleep 15
printf 'test_token=%s' "$T" | curl -s -X POST https://{site}/api/lead \
--data-binary @- -d 'email=probe@example.com' -d 'name=Test lead' \
-d 'form_type=inquiry' -d 'offer=contact' -d 'consent_version=inquiry-v1'
unset T
Expect JSON with platform: ok. The wait matters after any wrangler secret put: the previous deployment answers until the new one has propagated. Standalone sites skip this step; the form works without it.
- The form carries a honeypot and a one-line consent notice. Keep the notice, and bump the form's
consent_versionwhen its wording changes. The form sets no cookie. Add the form paragraph to the privacy page (the site configuration's Forms section has a draft to adapt): a policy that says contact happens by email is wrong the day the form goes live.
The form refuses submissions if the Turnstile secret is missing anywhere but localhost. A site never runs its form without the gate.
Gate: one real submission from a real browser, not curl: Turnstile passes, the visitor lands on /thank-you, the summary reaches the notification destination within seconds and the CRM if configured, and on a platform site the test lead returned platform: ok. A browser sends a form as a page navigation and command-line tools do not, so only a browser proves the form. /thank-you and /form-error are flat pages, both noindex; a refused submission gets the error page with the refusal's status code.
Build the pages
Homepage first. The homepage is built and approved before anything else. It carries both halves of the strategy: the messaging (hero, calls to action, navigation) and the look and feel (the tokens rendered as a real page). Positioning and visual direction are approved by approving a homepage, not by reading a document; everything after inherits that decision.
Then every other page from its spec. Each page has a spec block in the site configuration: URL, meta, keywords, schema, proof points by id, internal links, status. The agent builds against the spec and does not improvise. Shared navigation and footer live in the homepage file and propagate from there.
The QA scripts, and what each one catches:
npm run qa:driftfails on any nav or footer that is not byte-identical to the homepage's.npm run qa:screenshotrenders desktop and phone views of the pages you name, so a change is looked at, not only diffed.- Lighthouse, at ninety or above on all four categories including accessibility, on every page touched. Not an average.
npm run qa:redirectschecks every concrete rule in the redirects file for status and destination.npm run qa:cadencefails on any document underdocs/whose refresh date is older than its class allows.
Also before a page is done: no unresolved proof placeholder, FAQ schema matching the visible text, images with explicit dimensions.
Gate: the client approves the full site on the preview. Nothing flips until they do.
Existing site
When the business already has a site, build the new one in parallel on the preview while the old one keeps serving, and cut over at launch. Search presence is the asset at risk, so four things carry over regardless of design.
- Inventory from the live site. Capture every path, title, description, and existing redirect from the running site, not from memory. The redirect map depends on this list.
- Redirect map. Keep the old slugs wherever the new page list keeps the page. Every retired or moved path gets a permanent redirect in the redirect map document, mirrored to the
_redirectsfile, and tested with the redirect script before launch and again against the live domain after. - Canonical host. Read which of www or the bare domain the old site treats as canonical and keep it. The other host redirects to it.
- Asset snapshot. Download every image, download, and CSS-referenced background from the old host before cutover. Those files disappear with the old subscription.
Old-site defects (a dead email domain, tracking parameters in links, an empty page) are fixes, not questions; list them for the client's ruling. After cutover the old site stays reachable on its host's non-brand address for three to four months, or to the end of its contract, then is snapshotted and switched off.
Launch
- Create the zone, records first. Before adding the domain to Cloudflare, capture every DNS record at the registrar: nameservers, A, MX, TXT, CNAME, and the mail-signing selectors. Do not trust the import scan; recreate every mail record explicitly. Registrar-generated SPF macros die with the nameserver change, so write the direct form. Add a site-verification record for the operator's Google account so Search Console ownership does not depend on one person.
- Bind the domain to the worker as a custom domain. It binds fine on a pending zone, but nothing can be proven end to end until the nameservers move.
- Change the nameservers at the registrar. This is the only registrar action in the whole setup, and it is the client's to make. Three waits follow in order: the registry publishes, the zone activates, the certificate issues. The bare domain comes up first and www last; a www error inside that window is the wait, not a redirect bug.
- Noindex off, within minutes. Delete the single noindex line from
_headers, keep the rest of the block, commit, deploy. Then:
npm run qa:launch https://your-domain.com
It asserts that no noindex header remains, robots.txt permits crawling, the sitemap answers, and answer-engine crawlers are not blocked. A failing launch check is a blocker: a staging noindex surviving the flip is the most common serious launch bug there is.
- Sitemap and redirects. Submit the sitemap in Search Console. If the site carries migration redirects, run the redirect script against the live domain.
- Zone bot settings. The kit's robots.txt allows answer-engine crawlers on purpose, but a new Cloudflare zone ships with a managed rule that blocks them. Set the zone's AI crawler control to allow, turn off bot fight mode and the labyrinth feature, and confirm in the zone's security events that no allowed crawler is blocked. Edge blocks never reach the worker's logs. Then turn the workers.dev route off and revoke any temporary API token used for setup.
Gate: the launch check passes on the live domain.
After launch
The build plan becomes the ops doc. Same file: retitle it, mark the strategy sections frozen at launch with the date, and record the launch notes once under recent activity. In flight, recent activity, and the parking lot carry the site from here; the session loop does not change, only what the sessions are about. There is deliberately no separate launch document.
Change reports. Every change is a commit with a what-changed-and-why message; the kit's change-report convention turns those into a narrative for a marketing reader. Low-impact changes (copy, posts, templated pages) batch into the periodic report; high-impact ones (shared blocks, headers, redirects, robots, sitemap, tracking, pricing and legal pages) are flagged before they ship and reported the same day. Revert is one commit.
Content production. New content runs the kit's content-production runbook: spec from the template, draft, QA, published with noindex, reviewed at the live URL, then the index flag flipped and the page added to the sitemap. New content is never born indexable.
Monthly review. The monthly search and answer-engine review runbook reads performance, checks comparison pages for drift, and runs the cadence check. Learnings that would help every site go into the kit first, then back into the site.
Troubleshooting
Eight that account for most of launch week. A longer list follows when the operator guide is published.
- Mail broke after the nameserver change: a registrar SPF macro died with the delegation; write the direct SPF record into the zone.
- Analytics shows nothing: Zaraz is invisible to command-line checks and to GA4's test button; verify from a real browser and the real-time view.
- The site is live but not in search: the staging noindex survived the flip; run the launch check.
- AI crawlers blocked while robots.txt allows them: the zone's managed bot rule; fix it on the zone, not in the repository.
- A redirect does not fire: edit the redirect map first, mirror it to the redirects file, then run the redirect script.
- The form works from curl but a browser gets 405: the static-assets layer answered the form's navigation request before the worker ran;
wrangler.tomlneedsrun_worker_first = ["/api/*"]. The launch check tests for this. - Google Chat shows nothing while Slack works: Chat rejects a webhook body carrying any field it does not know; the kit posts
textonly. Check the worker's log linelead: target=chat outcome=. - A secret you just set is not in effect: wait fifteen seconds; the previous deployment answers until the new one has propagated.