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 |
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. 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 TURNSTILE_SECRET
The Function reads them from its env binding at runtime.
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.
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.