Rendering Dynamic Pages at the Edge
Cloudflare Workers lets developers move application logic to the network edge. One compelling use case is server-side rendering: serving static HTML that gets transformed on the fly by Worker code running close to the user. This approach combines the SEO and caching benefits of traditional server-rendered pages with the low latency of a globally distributed platform.
The model works like this: the site itself is a collection of static files stored in Workers KV. When a request arrives, a Worker at the nearest Cloudflare PoP runs the application logic, fetches any required data, and rewrites the static HTML into a customized response. Because the Worker is already at the edge, distribution—and caching—happen effortlessly.
Why Server-Side Rendering Is Making a Comeback
Early web pages were raw HTML—fast to serve and trivial to cache. The rise of dynamic scripting languages like CGI and PHP introduced server-side rendering: a server would assemble a page from a request's parameters and send back complete HTML. Proxies could cache these pages easily, and users saw content quickly since render-blocking JavaScript wasn't involved.
As broadband and powerful client hardware became common, the industry shifted toward client-side rendering. This gave us the app-like feel of tools such as Google Mail, where AJAX requests update page state without full reloads. But the client-side model has real downsides: larger payloads, slower time to interactive (TTI), degraded SEO for crawlers that don't execute JavaScript, and poor link previews in chat and social media apps.
Workers offer a way back. Application logic executes as a request comes into the network edge, producing fully rendered HTML without the traditional TTFB penalty of server round trips. The result is content that is cacheable, SEO-friendly, and fast—while still feeling like a responsive application.
Building a Serverless Rendering App
Peer With Cloudflare (PWC) is a reference implementation of this idea. Hosted at peering.rad.workers.dev, it uses the PeeringDB API to let users compare Cloudflare's network with any other autonomous system (ASN). The answer is a table of shared exchange points and facilities, rendered on the serverless edge.
The app is built as a Workers Site, which serves static HTML from Cloudflare's Key Value store.
> wrangler generate --site peering
Routing is simplified so that any URL path returns the same index.html single-page app. The asset handler method serveSinglePageApp makes this straightforward:
import { getAssetFromKV, serveSinglePageApp } from '@cloudflare/kv-asset-handler'
addEventListener('fetch', event => {
try {
event.respondWith(handleEvent(event))
} catch (e) {
if (DEBUG) {
return event.respondWith(
new Response(e.message || e.toString(), {
status: 500,
}),
)
}
event.respondWith(new Response('Internal Error', { status: 500 }))
}
})
async function handleEvent(event) {
/**
* You can add custom logic to how we fetch your assets
* by configuring the function `mapRequestToAsset`.
* In this case, we serve a single page app from index.html.
*/
const response = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp })
return response
}
Managing State with URL Parameters
PWC stores application state in the URL query string. This has two advantages: a user can bookmark or share a specific search, and the browser's native search box can be used to find ASNs. There is also a clear "null" state when no parameter is supplied. The asn parameter is read directly from the incoming request URL:
async function handleEvent(event) {
const response = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp })
const url = new URL(event.request.url) // create a URL object from the request url
const asn = url.searchParams.get('asn') // get the 'asn' parameter
}
The application logic needs to cover three cases:
- No
asnparameter is present—return the plain static page. - A valid ASN is supplied and found via the PeeringDB API.
- A malformed or unknown ASN is supplied—handle it as an error.
try {
if (asn) {
// B) asn is provided
} else {
return response
// A) no asn is provided; return index.html
}
} catch (e) {
// C) error state
}
Modelling Third-Party API Data
The PeeringDB API returns rich metadata for networks, peering exchanges, and facilities. PWC structures this data as three models: Network, NetworkFacility, and NetworkExchange. Each model handles its own population from the API, converting the responses into human-readable formats.
A network's constructor takes the ASN and registers it as an attribute:
export class Network {
constructor(asn) {
this.asn = asn
}
A populate() method fetches the data and builds the facility and exchange objects:
async populate(){
const net = await findAsn(this.asn)
this.id = net['id']
this.name = net['name']
this.website = net['website']
this.notes = net['notes']
this.exchanges = {}
for (let i in net['netixlan_set']) {
const netEx = new NetworkExchange(net['netixlan_set'][i])
this.exchanges[netEx.id] = netEx
}
this.facilities = {}
for (let i in net['netfac_set']) {
const netFac = new NetworkFacility(net['netfac_set'][i])
this.facilities[netFac.id] = netFac
}
return this
}
Comparing two networks is handled generically by a compare() method which delegates to compareItems() for shared exchanges and facilities:
compareItems(listA, listB, sharedItems) {
for (let key in listA) {
if(listB[key]) {
sharedItems[key] = listA[key]
}
}
return sharedItems
}
async compare(network) {
const sharedFacilities = this.compareItems(this.facilities, network.facilities, {})
const sharedExchanges = this.compareItems(this.exchanges, network.exchanges, {})
return await fetchAdditionalDetails(sharedFacilities, sharedExchanges)
}
The facility and exchange models follow the same pattern: initialize from API data, then populate extra information as required. Utility methods like findAsn and fetchAdditionalDetails live under src/utils/.
import {peeringDb} from './constants'
async function fetchPdbData(path) {
const response = await fetch(new Request(peeringDb['baseUrl'] + path))
const body = await response.json()
return body['data']
}
async function fetchAdditionalDetails(facilities, exchanges) {
const sharedItems = []
if (Object.keys(facilities).length > 0) {
const facilityDetails = await fetchPdbData(peeringDb['facEndpoint'] + "?id__in=" + Object.keys( facilities ).join(","))
for (const facility of facilityDetails) {
facilities[facility.id].populate(facility)
sharedItems.push(facilities[facility.id])
}
}
if (Object.keys(exchanges).length > 0) {
const exchangeDetails = await fetchPdbData(peeringDb['ixEndpoint'] + "?id__in=" + Object.keys( exchanges ).join(","))
for (const exchange of exchangeDetails) {
exchanges[exchange.id].populate(exchange)
sharedItems.push(exchanges[exchange.id])
}
}
return sharedItems
}
async function findAsn(asn) {
const data = await fetchPdbData(peeringDb['netEndpoint'] + "?" + `asn__in=${asn}&depth=2`)
return data[0]
}
export {findAsn, fetchAdditionalDetails}
Rewriting HTML with HTMLRewriter
To produce personalized responses, PWC relies on the HTMLRewriter API. This interface streams a response through one or more transformers, which inspect and mutate the HTML on the fly. A handler class defines the transformation logic.
For the null case no transformation is performed. When a user supplies an ASN, the #asnField element is populated so the value is visible in the form after submission:
class AsnHandler {
constructor(asn) {
this.asn = asn
}
element(element) {
element.setAttribute("value", this.asn)
}
}

An error condition—bad or unfindable ASN—requires appending a header to the output:
class ErrorConditionHandler {
constructor(asn) {
this.asn = asn
}
element(element) {
element.append(`<h4>ASN ${this.asn} Not Found on PeeringDB</h4>`, {html: true})
}
}

The most complex case involves rendering a comparison table. Rather than building HTML strings directly, PWC uses Handlebars as a templating engine. The template file defines conditional logic for cases with no overlap and iterates over data to build rows. Handlebars also supports custom helpers, which PWC uses to generate anchor links for each row:
import handlebars from 'handlebars'
export default function(text, url) {
return new handlebars.SafeString("<a href='" + handlebars.escapeExpression(url) + "'>" + handlebars.escapeExpression(text) + "</a>");
}
Configuration for Templating
Using Handlebars requires a custom webpack configuration. First install the loader and library:
> npm install handlebars handlebars-loader
Then reference the .hbs file in the handler and configure webpack to compile it:
module.exports = {
target: 'webworker',
entry: './index.js',
module: {
rules: [{ test: /\.hbs$/, loader: 'handlebars-loader' }],
}
}
Finally, the transformation handlers are wired up in index.js. The #formContainer holds either the comparison table or an error message, and the #asnField shows the submitted value:
async function handleEvent(event) {
const response = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp })
const url = new URL(event.request.url)
const asn = url.searchParams.get('asn')
try {
if (asn) {
const cfNetwork = await new Network(cloudflare['asn']).populate()
const otherNetwork = await new Network(asn).populate()
const sharedItems = await cfNetwork.compare(otherNetwork)
return await new HTMLRewriter()
.on('#asnField', new AsnHandler(asn))
.on('#formContainer', new NetworkComparisonHandler({cfNetwork, otherNetwork, sharedItems}))
.transform(response)
} else { return response }
} catch (e) {
return await new HTMLRewriter()
.on('#asnField', new AsnHandler(asn))
.on('#formContainer', new ErrorConditionHandler(asn))
.transform(response)
}
}

Deploying and Caching at the Edge
Publishing requires only a wrangler command, provided that workers_dev is set to true in the wrangler.toml:
> wrangler publish
Because responses are fully rendered HTML, they are natural candidates for the Cache API. Adding a lookup at the start of the request handler means cached pages bypass all application logic on subsequent hits, cutting both origin work and user latency even further:
async function handleEvent(event) {
let cache = caches.default
let response = await cache.match(event.request)
if (response) {
return response
}
response = await getAssetFromKV(event, { mapRequestToAsset: serveSinglePageApp })
The static template in public/ includes styling via the milligram framework, which pulls in normalize.css and the Roboto font family. All view and network code—including the Helmet-required parser files—resides under npm run-style scripts managed by wrangler on deploy.
The Takeaway
Workers Sites plus HTMLRewriter gives developers a straightforward way to deliver server-rendered pages from a serverless, globally distributed platform. The edge-compute model eliminates the traditional tradeoffs of server-side rendering: near-zero cold start times, no origin round trips, and easy caching of fully formed pages. Whether you're measuring SEO impact, improving link preview fidelity, or simply reducing TTI, this pattern gives you the old benefits of server-side rendering with the new benefits of modern edge infrastructure.
The full source for Peer With Cloudflare is on GitHub, and the live application is running at peering.rad.workers.dev. More examples of Workers-based applications are compiled on the Built With Workers page.



