The Stack Behind the Signup
Cloudflare’s Summer Developer Challenge asks participants to build with at least two products from the Cloudflare developer platform. The challenge’s own landing page and registration flow meet that bar: it’s a Workers Site built from a single Worker backed by one Workers KV namespace, with static HTML pages handling the front end and a small API layer for submissions.
Workers Sites predates Pages and is essentially a pattern for building a monolith inside a Worker. All asset requests and API calls route through the same Worker, which is deployed globally. For this project, routing is handled by the worktop web framework—a router plus utilities—which made it easy to sketch the whole application structure upfront, even before any handler logic was written.
import { Router } from 'worktop';
import * as Cache from 'worktop/cache';
const API = new Router;
API.add('GET', '/', (req, res) => {
res.send(200, 'TODO: send HTML for landing page');
});
API.add('GET', '/rules', (req, res) => {
res.send(200, 'TODO: send HTML for terms & conditions');
});
API.add('POST', '/signup', (req, res) => {
res.send(201, 'TODO: parse & save initial registration');
});
API.add('GET', '/submit', (req, res) => {
res.send(200, 'TODO: render the unique submission form');
});
API.add('POST', '/submit', (req, res) => {
res.send(201, 'TODO: parse, validate, save submission data');
});
// init; w/ Cache API
Cache.listen(API.run);
Self-Contained Pages
A typical Workers Site uploads assets to KV and uses @cloudflare/kv-asset-handler to serve them. This project took a different path: instead of serving external CSS and JavaScript files, a custom build step inlines those assets directly into the HTML. That means each page makes zero additional network requests for resources—the document alone is enough.
This wasn’t purely a performance decision. Inlining assets also removed the need for extra URL routing, KV asset uploads, and separate cache lifetimes. To make it work, the build script reads HTML files as strings and looks for HTML comments in the <!-- inject:(path) --> format, replacing them with the contents of the referenced file. Stylus files are converted to CSS before being embedded:
<!-- submit/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Submit Project | Cloudflare Developer Summer Challenge</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="https://www.cloudflare.com/favicon-128.png">
<!-- inject:submit/index.styl -->
<!-- inject:index.js -->
</head>
<body>
<!-- ... -->
</body>
</html>
In production builds, the complete HTML document—including any inlined CSS or JavaScript—is passed through a minifier to reduce bytes on the wire. The result is visible in the network panel when loading the main page: the only external request is for the favicon, with a couple of Blob URLs that don't actually transfer data.

Bringing HTML Into the Worker
Rather than writing HTML strings directly inside the Worker code—which gets unmaintainable quickly—the build system produces static HTML files first, then bundles them into the Worker.
The Worker is authored in TypeScript and built with esbuild, which transpiles TypeScript and also supports plugins. A custom esbuild plugin lets the Worker import HTML files directly. That keeps the code readable and lets common patterns be extracted into reusable helpers:
import { Router } from 'worktop';
import * as Cache from 'worktop/cache';
// loaded via esbuild plugin
import LANDING from 'index.html';
import RULES from 'rules/index.html';
API.add('GET', '/', (req, res) => {
res.setHeader('Content-Type', 'text/html;charset=utf-8');
res.setHeader('Cache-Control', 'public,max-age=60');
res.send(200, LANDING);
});
API.add('GET', '/rulees', (req, res) => {
res.setHeader('Content-Type', 'text/html;charset=utf-8');
res.setHeader('Cache-Control', 'public,max-age=1800');
res.send(200, RULES);
});
// ...
// init; w/ Cache API
Cache.listen(API.run);
A render utility function was the first of these helpers, handling the common case of returning an HTML response. Many pages also need to inject dynamic values—the landing page shows remaining prize count, and the submission form should pre-fill the participant's name and email. To handle this, the project uses a {{ variable }} syntax in HTML, with substitutions made at request time. The landing page handler, for instance, checks KV for the latest ::remain value and replaces it in the template:
// worker/utils.ts
import type { KV } from 'worktop/kv';
import type { ServerResponse } from 'worktop/response';
// TypeScript placeholder
// Defines the `DATA` KV binding
declare const DATA: KV.Namespace;
export function render(res: ServerResponse, template: string, values: Record<string, string> = {}) {
for (let key in values) {
template = template.replace('{{ ' + key + ' }}', values[key]);
}
res.setHeader('Content-Type', 'text/html;charset=UTF-8');
res.send(200, template);
}
export function toCount(): Promise<string> {
return DATA.get('::remain', 'text').then(v => v || '300+');
}
// worker/index.ts
import * as utils from './utils';
API.add('GET', '/', async (req, res) => {
// Get the "::remain" count from KV
const count = await utils.toCount();
// Short-term TTL for remaining swag updates
res.setHeader('Cache-Control', 'public,max-age=60');
// Render the HTML, passing in `count` variable
return utils.render(res, LANDING, { count });
});
This same pattern appears across nearly every HTML response in the project.
Handling Form Submissions
The app relies heavily on form submissions, and parsing them is straightforward thanks to the Fetch API’s built-in body parsers. worktop adds a convenience wrapper, req.body(), that automatically picks the right parser based on the request’s Content-Type header.
Validation follows a familiar pattern: an input object, a set of rules, and a loop that collects error messages. The utils.validate helper implements this, letting validation rules be declared inline. In the POST /submit handler, validation is the gatekeeper before any data is written to KV:
// worker/index.ts
import * as utils from './utils';
API.add('POST', '/signup', async (req, res) => {
try {
var input = await req.body<Entry>();
} catch (err) {
return toError(res, 400, 'Error parsing input');
}
let { email, firstname, lastname } = input || {};
firstname = String(firstname||'').trim();
lastname = String(lastname||'').trim();
email = String(email||'').trim();
let { errors, invalid } = utils.validate({
email, firstname, lastname
}, {
email(val: string) {
if (val.length < 1) return 'Required';
return utils.isEmail(val) || 'Invalid email address';
},
firstname(val: string) {
return val.length > 1 || 'Required';
},
lastname(val: string) {
return val.length > 1 || 'Required';
}
});
if (invalid) {
return res.send(422, errors);
}
// The `input` is valid!
return res.send(200, 'TODO: finish me');
});
Once the data passes validation, the registration flow performs several steps:
- Verify the
input.emailisn’t already registered - Store the registration in KV under the
user:<email>key - Generate a unique submission code, which ensures only registered users can submit and only once
- Email the user a link with their unique submission code
- Render a confirmation page telling them to check their inbox
With utility helpers in place, the handler is compact:
// worker/index.ts
import * as utils from './utils';
import * as Sparkpost from './emails';
import * as Signup from './signup';
import * as Code from './code';
function toError(res: ServerResponse, status: number, reason: string) {
return res.send(status, { status, reason });
}
API.add('POST', '/signup', async (req, res) => {
try {
var input = await req.body<Entry>();
} catch (err) {
return toError(res, 400, 'Error parsing input');
}
let { email, firstname, lastname } = input || {};
firstname = String(firstname||'').trim();
lastname = String(lastname||'').trim();
email = String(email||'').trim();
// truncated: validation
// Ensure email is not already in use
let exists = await Signup.find(email);
if (exists) return toError(res, 400, 'You have already signed up');
// Generate new `Entry` record
let entry = Signup.prepare({ email, firstname, lastname });
// create "user:<unique email>" document
let isOK = await Signup.save(entry);
if (!isOK) return toError(res, 500, 'Error persisting entry');
// create "code:<unique value>" document
isOK = await Code.save(entry);
if (!isOK) return toError(res, 500, 'Error saving unique code');
// dispatch "We received your registration" email
let sent = await Sparkpost.confirm(entry);
if (!sent) return toError(res, 500, 'Error sending confirmation email');
// render "Thank you, check your {{ email }} for next steps" page
return utils.render(res, CONFIRM, { email: entry.email });
});
The handler returns a full HTML response, which the client-side script inserts directly into the page. Because the forms are semantically correct and the server returns complete documents, the whole flow works even with JavaScript disabled—validation becomes a degraded experience with no modal errors, but submission still succeeds.
// (client) index.js
$('form').onsubmit = async function (ev) {
ev.preventDefault();
var form = ev.target;
var res = await fetch(form.action, {
method: form.method || 'POST',
body: new FormData(form),
});
// truncate: clear existing errors
if (res.ok) {
form.reset();
// Receive HTML response
let html = await res.text();
// Force-write the new HTML into this window
document.documentElement.innerHTML = html;
} else {
// truncate: render errors
}
};
Sending Confirmation Emails
Transactional email is a solved problem. The project uses SparkPost, but the mechanics are similar across providers: get an API token, POST to an endpoint with the token in an Authorization header plus recipient and content in the body, then handle the response.
SparkPost lets templates be referenced by name, which is handy for debugging. Templates accept variables—the same concept as the utils.render function used for HTML. The email formatting code is mostly type hints:
// worker/emails.ts
import type { Entry } from './signup';
// wrangler secret
// @see https://developers.sparkpost.com/api/#header-authentication
declare const SPARKPOST_KEY: string;
/**
* Assemble the POST request for all SparkPost email triggers
* @see https://developers.sparkpost.com/api/transmissions/#transmissions-post-send-a-template
*/
async function send(
templateid: string,
recipient: Entry,
values?: Record<string, string>
): Promise<boolean> {
const res = await fetch('https://api.sparkpost.com/api/v1/transmissions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': SPARKPOST_KEY,
},
body: JSON.stringify({
content: {
template_id: templateid,
},
recipients: [{
address: {
email: recipient.email,
name: recipient.firstname + ' ' + recipient.lastname,
},
substitution_data: values || {},
}]
})
});
let data = await res.json() as {
results: {
id: string;
total_rejected_recipients: number;
total_accepted_recipients: number;
}
};
return res.ok && data.results.total_accepted_recipients === 1;
}
/**
* Confirming user's signup
* Sending unique submission form
*/
export function confirm(entry: Entry): Promise<boolean> {
return send('devchallenge-confirm', entry, {
firstname: entry.firstname,
code: entry.code,
});
}
The confirm method sends the unique submission link with firstname and code as template variables for the “devchallenge-confirm” template.
Verdict: Small Cost, Big Headroom
On the whole, the project was a clear win. The Workers runtime continues to punch above its weight, letting a single application stitch together a content cache and globally-replicated storage with barely any operational overhead. In practice, that meant the heavy conceptual lifting was front-loaded; building the actual API handlers was straightforward compared to the time invested in the custom build pipeline for the client-side assets.
The economics are just as compelling. Even a hypothetical flood of traffic from a viral launch would keep the monthly bill in the single digits. That kind of headroom makes experimentation feel cheap, which is precisely the point of the platform.
And while optimizing the HTML build output was a rabbit hole, it did pay off measurably:

What I'd Do Differently
The biggest architectural takeaway is that Workers Sites was not the optimal fit for this app. A better path would have been a Pages project for static assets, with a Worker in front handling the API and dynamic KV injection needs.
Such a split would have meant two things. First, no longer embedding static files in the Worker source. Instead, a new utility could fetch from the Pages URL (acting as the origin) and use HTMLRewriter to splice in the dynamic variables on the fly. Second, dropping the largest single contributor to the Worker's byte size, even though the 1MB limit was never a real concern.
More importantly, that refactor would have drastically simplified the toolchain. The source of most of the project's complexity was the bespoke frontend build system. If static assets were just static assets, standard frameworks could have handled them without a custom bridge into a Worker.
None of this is a knock on Workers Sites. It handled the job well. The point is that the platform gives you a choice: lean on well-worn paths with Pages, and let the platform manage pipelines and deployments, or take the full control offered by a custom setup when you need it. Both are valid routes to the same destination.



