Guide
Cloudflare Workers / Pages Functions
SSG generates static HTML. The transactional parts of a commercial site — payments, form submissions, dynamic pricing, server-side conversion tracking — need code that runs per request. On Cloudflare Pages that code is a Pages Function, and SSG wires one into the same build and the same deployment.
The split is deliberate: SSG for content, Workers for transactions. The
content pages stay static (fast, cacheable, cheap); only the handful of /api/*
routes hit a Function.
The worker: section
1worker:
2 dir: workers/contact-form # a Pages Functions project (contains functions/)
3 mode: functions # "functions" (default) or "worker"
4 routes_include: # what reaches the Function; default ["/api/*"]
5 - /api/*
6 routes_exclude: [] # carve static paths back out if needed
7 wrangler_config: "" # optional wrangler.toml outside the project root
At build time SSG:
- copies
dir(itsfunctions/tree, or a prebuilt_worker.js) into the output, and - writes
_routes.jsonfromroutes_include/routes_exclude, so every path not matched byincludeis served as a static asset and never invokes the Function.
worker: is empty by default; without it the build is static-only, unchanged.
Several workers: the workers: list
One site can need more than one worker — a cookie-consent endpoint and a
comments API, say. workers: is the plural form: a list of independent
worker definitions, each with its own routes, config and source. When set it
supersedes the singular worker: (which stays for back-compat).
1workers:
2 - name: cookie-consent
3 dir: workers/cookie-consent
4 routes_include: [/api/consent]
5 config: # free-form, surfaced to this worker
6 countries: [DE, FR, PL]
7
8 - name: comments
9 source: https://github.com/acme/ssg-comments # fetched, not vendored
10 auth: # private repo (optional)
11 type: bearer
12 token: $GITHUB_TOKEN # env ref, never a literal
13 routes_include: [/api/comments]
14 config:
15 d1_binding: COMMENTS
16 retention_days: 90
Per entry:
| Key | Purpose |
|---|---|
name |
identifies the worker (logging, collision messages, config: key) |
dir |
local source directory; where a fetched source: lands |
source |
optional repo/zip URL to fetch the worker from (GitHub/GitLab repo, or a .zip) |
auth |
credentials for a private source: — bearer / basic / header, secrets as env refs |
mode, routes_include, routes_exclude, wrangler_config |
as for the singular worker: |
config |
free-form settings block passed through to the worker |
How the build treats them: Cloudflare Pages serves a single functions/
tree and one _routes.json per project, so the workers' functions are copied
into that shared tree and their routes are combined. Combining normalises:
Cloudflare rejects a _routes.json whose rules overlap, so a rule already
covered by a splat in the same list is folded into it — /api/contact and
/api/consent/* beside /api/* are published as /api/* alone, and the build
says which rules were absorbed. Include and exclude are normalised separately.
This matters most for a middleware worker, which legitimately covers every route
rather than owning one, and for a worker that names no routes at all: it
defaults to /api/*, which is exactly the value that overlaps. Because they are
independent, two workers claiming the same output file is a hard error
(never a silent overwrite) — give them distinct routes. Only one worker may use
mode: worker (a project has one _worker.js).
A source: is fetched into dir (default workers/<name>) once and reused on
later builds — an already-populated directory is not re-fetched, so a build is
not gated on the network. Vendor the fetched worker (commit it) for
reproducible builds, or keep source: to always track upstream.
mode: functions vs mode: worker
| Mode | dir contains |
Deploy path |
|---|---|---|
functions (default) |
a functions/ directory of .ts/.js handlers |
wrangler pages deploy (Cloudflare builds them) |
worker |
a prebuilt, bundled _worker.js |
pure-Go Direct Upload — no Node/wrangler needed |
Use functions for normal development. Use worker when you want SSG's
dependency-free Direct Upload deploy and have already bundled your Worker
yourself (e.g. with esbuild in CI).
Scaffolding a Function
ssg new worker <template> drops a batteries-included, npm-dependency-free
template under ./workers/<template>/ and prints the worker: block to add:
| Template | What it does |
|---|---|
contact-form |
POST /api/contact — Turnstile verify, email via MailChannels (Resend optional) |
stripe-checkout |
POST /api/checkout (Checkout Session) + POST /api/stripe-webhook (HMAC signature verify) |
dynamic-price |
GET /api/price/:sku from KV or an upstream API, plus a client snippet |
conversions-proxy |
POST /api/track — server-side Meta CAPI relay with SHA-256-hashed PII |
cookie-consent |
GDPR/UK cookie banner: edge geo (EEA+UK), granular categories, script-gating, Consent Mode v2, optional audit log; ships a starter cookie-policy.md. ssgtheme wires it from variables.cookie_consent rather than literal HTML — see its README |
comments |
Comments in D1: Turnstile, moderation panel behind a password, heuristic/Akismet spam filter, no accounts, IP kept only as a salted hash. Ships a widget and an admin page. See its README |
republish-trigger |
POST /api/republish — one authenticated webhook that fires a CI build on GitHub / GitLab / Gitea (a CMS webhook, cron or curl can redeploy the site). Key-gated, provider token stays server-side, optional KV debounce. See its README |
newsletter |
Newsletter / waitlist sign-up in D1: Turnstile, required consent stored with its wording, identical answer for new and existing addresses, RFC 8058 one-click unsubscribe, optional double opt-in (Resend or MailChannels), admin counts + CSV export, retention purge, IP/UA kept only as salted hashes. Ships a form that works without JavaScript. See its README |
rate-limit |
functions/_middleware.ts — a request budget for every Function in the project, including ones added later. Exact and free through the Workers Rate Limiting binding, KV as a fallback. See its README |
1ssg new worker stripe-checkout
Each template ships a README.md listing the secrets it needs.
Bounding how often they can be called
Every template above that writes something — sends an email, stores a comment, logs a consent — is a public, unauthenticated endpoint. Turnstile raises the cost of abusing one without capping it: a solved token can be replayed inside its validity window, and Turnstile is optional in the first place.
rate_limit / rate_burst in .ssg.yaml bound the built-in preview server
only. rate-limit is the deployed counterpart:
1ssg new worker contact-form
2ssg new worker rate-limit
3cp -r workers/rate-limit/functions/_middleware.ts workers/contact-form/functions/
It is middleware, so it wraps whatever is in functions/ without either side
knowing about the other. RATE_LIMIT_SKIP carves paths back out — the
newsletter's one-click unsubscribe, POSTed by mailbox providers from a few
shared addresses, is the case it exists for. Without a backend bound it is a no-op — a limiter
that turns visitors away because nobody finished configuring it is worse than no
limiter — and it fails open by default, which is right for a contact form and
wrong for a checkout, so RATE_LIMIT_FAIL = "closed" is there for the latter.
Prefer the RATE_LIMITER binding over KV. KV is eventually consistent, so a
burst arriving at several points of presence at once can overshoot the cap,
which is exactly the shape a spam run takes.
Secrets
Secrets never live in .ssg.yaml or in the Function source — set them per Pages
project with wrangler:
1wrangler pages secret put STRIPE_SECRET_KEY
2wrangler pages secret put CONTACT_TURNSTILE_SECRET
The Function reads them from its env binding at runtime.
One project, one environment: prefix your secrets
Cloudflare Pages gives a project one set of variables and secrets, and every
worker merged into that project's functions/ tree reads all of it. A secret
set for one template is therefore visible to every other — and a template that
reads an unprefixed name picks up a value meant for someone else.
That is how a shared TURNSTILE_SECRET used to break the cookie-consent audit
log: set for contact-form or comments, it switched Turnstile verification on
in cookie-consent too, whose banner never sends a token, so every consent
record was dropped with {"ok":true,"stored":false} (#325).
Each template now reads its own prefixed name first:
| Template | Turnstile secret | Falls back to TURNSTILE_SECRET? |
|---|---|---|
contact-form |
CONTACT_TURNSTILE_SECRET |
yes — existing projects keep working |
comments |
COMMENTS_TURNSTILE_SECRET |
yes — existing projects keep working |
newsletter |
NEWSLETTER_TURNSTILE_SECRET |
yes |
cookie-consent |
CONSENT_TURNSTILE_SECRET (opt-in, audit log only) |
no — the shared name is ignored |
The fallback means nothing changes for a project that already sets
TURNSTILE_SECRET for its contact form or comments, except that the consent log
starts storing records again. New projects should set the prefixed names. The
same rule applies to anything that switches behaviour on: the newsletter's
double opt-in keys (NEWSLETTER_RESEND_API_KEY, NEWSLETTER_MAILCHANNELS_API_KEY)
deliberately have no unprefixed fallback.
Local development
1ssg --config .ssg.yaml --http --watch
When a functions-mode worker is set, --watch serves the pages and the
Functions together by running wrangler pages dev . from the build output
directory (that is where SSG copies each worker's functions/, and where
wrangler pages dev looks for them). SSG also generates a starter
wrangler.toml first if the project has none (see below), so bindings are
available. A prebuilt mode: worker keeps wrangler dev from its own
directory. An explicit watch_runner (or --wrangler/--workerd) overrides
all of this.
The runner needs Node.js (npx wrangler). If the runner is not installed, the
preview still starts, and ssg says once that /api/* is not served. This is
the case in the ssg Docker image, which has no Node. Two ways to handle it:
- Run
npx wrangler pages dev outputon the host, beside the container, for the Functions. - Set
watch_runner: noneto preview the static site alone without the note.
Generating a wrangler config
wrangler pages dev and wrangler pages deploy read a wrangler.toml for the
build output directory and any bindings. When a project uses workers and has no
wrangler config, SSG writes a starter one — on --watch, or on demand:
1ssg new wrangler
It derives name from the domain and pages_build_output_dir from the output
dir, and appends each worker's wrangler.snippet.toml — a fragment the worker
ships declaring its bindings and vars (e.g. cookie-consent's optional
CONSENT_LOG KV namespace). An existing wrangler config, or one named via a
worker's wrangler_config, is never overwritten.
Deploy
1ssg --config .ssg.yaml --deploy cloudflare --deploy-project my-site
- With a
functions/tree, SSG shells out tonpx wrangler pages deployso Cloudflare builds the Functions.CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDare read from the environment. - With
mode: worker(a prebuilt_worker.js), the pure-Go Direct Upload path is used — no wrangler, no Node.
If wrangler is required but missing, the deploy fails with an actionable
message; switch to mode: worker with a prebuilt bundle to avoid the Node
dependency.
Deployment topologies (which requests reach your Functions)
Whether a workers: endpoint actually runs depends on the shape of your
Cloudflare deployment, not just on routes_include. Two caveats bite silently.
| Topology | What runs | Functions active? |
|---|---|---|
Pure static (no worker:/workers:) |
assets only | — |
Functions mode (mode: functions, the default) |
functions/ tree per _routes.json |
yes |
Advanced mode (a _worker.js at the output root) |
that single Worker for every request | no |
Zone Worker route (e.g. example.com/api/*) owns a prefix |
the zone Worker on that prefix | shadowed there |
- Advanced mode disables Functions entirely. If a
_worker.jssits at the output root (Pages "advanced mode"), Cloudflare runs only that Worker and thefunctions/tree is ignored — so everyworkers:endpoint is silently dead. Pick one: thefunctions/model (mode: functions) or a single hand-built_worker.js(mode: worker) that does its own routing. SSG'sworkers:merge targets the Functions model. - Zone-level Worker routes shadow Function routes. A route like
example.com/api/*bound to a standalone Worker on the zone runs before Pages Functions, so aworkers:endpoint under the same prefix (say/api/consent/*) never fires. Either move the endpoint off the owned prefix, or carve it out of the zone route. A quick smoke test after deploy —curleach endpoint path — catches both cases before your visitors do.
What SSG does not do
- No JS/TS bundler — Cloudflare Pages builds Functions from source;
mode: workercovers prebuilt bundles. - No secret management — that is
wrangler pages secret put. - No KV/Durable Object provisioning — bind those in the Pages dashboard.