A static marketing site needs a backend for forms. Newsletter signup, contact form, design partner application. Three tiny endpoints that need to validate input, defend against bots, push to a CRM, and respond to the user. Nothing more.
We did this on Cloudflare Pages Functions. This post is the pattern, with the specific shape of what we shipped.
What Pages Functions are
A Cloudflare Pages project can include a functions/ directory. Any .js file in there with a default export becomes an HTTP endpoint. The path mirrors the filesystem: functions/api/newsletter.js serves /api/newsletter.
Under the hood, these are Cloudflare Workers wired to your Pages project’s environment. Same deploy. Same env vars. Same logs. Same edge network. You do not need a separate Workers project.
For a marketing site, this is enough backend.
The endpoint shape
Each endpoint is the same shape:
export async function onRequestPost({ request, env }) {
// 1. Parse JSON body
// 2. Validate required fields
// 3. Verify Cloudflare Turnstile token
// 4. Log the submission (always)
// 5. Push to Zoho (best-effort)
// 6. Return success or error
}
The order matters. Logging happens BEFORE the Zoho push so that if Zoho is down, the submission is still captured in Cloudflare’s function logs. The CRM push is best-effort.
Cloudflare automatically routes by method: onRequestPost handles POST, onRequestGet handles GET, etc. Methods without a specific handler get a 405 from Cloudflare’s dispatcher. You do not need to write the method-not-allowed branch yourself.
Shared helpers
We have three endpoints. They share three concerns:
- JSON response construction (
jsonResponse(body, status)). - Cloudflare Turnstile verification (call siteverify, return ok/error).
- Zoho OAuth token mint (refresh-token to access-token).
These live in functions/api/_lib/. The underscore prefix tells Cloudflare not to route them as endpoints. Each endpoint imports what it needs:
import { jsonResponse } from './_lib/response.js'
import { verifyTurnstile } from './_lib/turnstile.js'
import { mintZohoToken, ZOHO_CRM_REGION_HOSTS } from './_lib/zoho.js'
This is the standard “shared helpers” extraction. Without it, the Zoho token mint code was copy-pasted byte-for-byte across endpoints. With it, the helper exists once.
Turnstile, the graceful-degradation version
Cloudflare Turnstile is the no-CAPTCHA bot challenge. The client widget produces a token; the server verifies it against Cloudflare’s siteverify endpoint.
Our verifyTurnstile helper has a specific quirk: if TURNSTILE_SECRET_KEY is not set in the environment, it returns {ok: true, skipped: true} instead of failing. This let us deploy the site before the Turnstile widget was provisioned in the Cloudflare dashboard. The forms worked end-to-end without bot protection, then we added the widget and the same code began enforcing it.
export async function verifyTurnstile(env, token, request) {
if (!env.TURNSTILE_SECRET_KEY) {
return { ok: true, skipped: true }
}
if (!token) return { ok: false, error: 'Missing Turnstile token' }
// ... siteverify call ...
}
Graceful degradation when a feature is not yet provisioned is a pattern that saved us multiple times during initial deploy. It applies to other “optional” backends too.
Zoho OAuth: the part that surprised us
Zoho’s OAuth flow is standard refresh-token-grant. The non-standard part is that Zoho has multiple data centers, each with its own accounts host and API host. A token minted on accounts.zoho.com (US) does not work on zohoapis.in (India). The region needs to match.
Our env vars include a ZOHO_REGION value (us, eu, in, etc.). The helper maps that to the right hosts:
export const ZOHO_CRM_REGION_HOSTS = {
us: 'https://www.zohoapis.com',
eu: 'https://www.zohoapis.eu',
in: 'https://www.zohoapis.in',
// ...
}
export function zohoAccountsHost(region) {
return `https://accounts.zoho.${region === 'us' ? 'com' : region}`
}
The accounts host follows the region’s TLD except for US, which is .com. This is the kind of detail that takes ten minutes to discover and is annoying enough to want to write down once.
The second non-obvious thing about Zoho: refresh tokens carry scope. A refresh token minted with ZohoCRM.modules.leads.CREATE scope cannot read from Zoho Campaigns. We mint a single combined-scope token covering both products and use it everywhere.
Zoho Campaigns: the v1.1 endpoint quirk
Adding a contact to a Zoho Campaigns mailing list looks straightforward in the docs. There is an endpoint called listsubscribe. We tried it first.
It returns HTTP 401 with an HTML body. The 401 is wrong: the OAuth token is fine, the scope is right. The endpoint has just never been updated for OAuth authentication; it expects the older authtoken scheme.
The endpoint that does accept OAuth is addlistsubscribersinbulk. It is the same operation in functional terms (add an email to a list) but uses the modern auth path. We use this one. The docs do not flag this divergence.
The response shape also has a gotcha: contacts that get filtered by Zoho’s anti-spam are returned in an ignored_contacts array with HTTP 200. The request “succeeded” but the contact was rejected. We log this case as a warning rather than as a failure.
Logging is the actual safety net
Every endpoint logs the full request to Cloudflare’s function logs before doing anything else. The log line includes timestamp, IP, user agent, and all submitted fields.
This means: if Zoho is down, if the CRM credentials are misconfigured, if the integration breaks for any reason, the submission is still captured. We can pull it from logs and manually re-enter it.
Cloudflare’s function logs are retained for 30 days. For a low-volume marketing site, this is plenty. We can grep submissions by prefix ([contact], [newsletter-signup], [design-partner-application]) and act on them out-of-band if needed.
This pattern is worth applying to every “best-effort” integration. The log line is the source of truth; the integration is a convenience.
What we did not need
For comparison with patterns we considered:
- No queue. Pages Functions are fast enough to handle our submission rates synchronously. Queues add complexity we do not need below several requests per second.
- No database. Zoho is the system of record. The function is a translator from our form schema to Zoho’s API.
- No retry logic. Failed Zoho pushes are logged and dropped. The submission is in our function logs; if we need to recover, we can re-run by hand. Building exponential backoff for an HTTP call that succeeds 99.5% of the time is over-engineering.
- No authentication on the endpoints themselves. These are public form submissions. The bot defense is Turnstile, not API keys.
Deploy
The full deploy story is: push to main. Cloudflare’s CI runs npm run build, deploys the static output to Pages, and deploys the function bundle to the same project. Total time from push to live is about 90 seconds.
Env vars are managed in the Cloudflare dashboard under the Pages project’s settings. Secrets are marked as encrypted. They are injected into the Workers runtime when the function executes.
For a marketing site with three forms, this is the right amount of backend. It scales to several million requests per month before we would need to think about anything else.
If you are picking a stack for a similar site, this pattern is worth copying. The Pages Functions documentation is thin in places, but the primitives are solid.
For the broader stack write-up, see Why we built Credostar’s marketing site on Astro 5 and Cloudflare Pages.