Ship a product, get a support button for free: an edge-injected overlay on Cloudflare Workers
Yesterday I audited 42 repositories to fix missing Sponsor buttons. Today is the follow-up: extending the support links from repo READMEs to the product screens themselves.
This site has a /products/ hub for mini-products. The first one is historymap, a YAML-to-timeline generator, and more will land there on a weekly cadence. What I wanted:
- A Ko-fi / GitHub Sponsors link in the bottom-right corner of every product
- Without touching any product’s code
- Future products should get it with zero extra work
- Later, the same mechanism should carry per-URL ad modals
I ended up with edge injection via Cloudflare Workers’ HTMLRewriter. This post records the design decisions, and a trap where a deployed Worker never executed a single line.
The setup: each product is an independent Worker behind a Route
kenimoto.dev/products/<app>/ is served by Workers that are independent from the main site.
kenimoto.dev/* → Worker: kenimoto-dev (main site)
kenimoto.dev/products/historymap/* → Route → Worker: historymap (own repo)
Cloudflare gives a more specific Route priority over the Custom Domain, so the main site stays untouched and each product costs exactly one Route. App = repo = Worker = Route, one-to-one; sunsetting or promoting a product is a Route-level operation. I like this shape a lot.
The price of that separation: the main site’s layout never reaches product pages. If I want a support button there, something has to lay it on top after the fact.
The iframe wrapper idea, rejected
My first candidate was a shell page on the main site with the product in an iframe. The support links live in the shell, and the implementation is trivial. I compared and dropped it.
| Aspect | iframe wrapper | Edge injection |
|---|---|---|
| SEO / AI crawlers | Shell page is an empty box | The page itself gets indexed |
| URL vs. screen state | Inner navigation strands the outer URL | No issue |
| Units to manage | App hosting + shell page, doubled | Still one Worker |
| Implementation | postMessage height-sync as permanent debt | A few dozen lines of HTMLRewriter |
SEO was the decider. Iframe content is not credited to the parent page, which collides head-on with the whole point of hosting products under kenimoto.dev/products/<app>/: accumulating link equity from search engines and AI crawlers. Sacrificing the asset to decorate it would be backwards.
The loader pattern: separate the wiring from the substance
Edge injection itself is easy. Each product Worker used to be Static Assets only; I added one script.
const OVERLAY_TAG =
'<script src="https://kenimoto.dev/assets/products-overlay.js" defer></script>';
export default {
async fetch(request, env) {
const response = await env.ASSETS.fetch(request);
if (new URL(request.url).hostname !== 'kenimoto.dev') return response;
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html')) return response;
return new HTMLRewriter()
.on('body', {
element(el) {
el.append(OVERLAY_TAG, { html: true });
},
})
.transform(response);
},
};
The design point: what gets injected is a single script tag. The button UI, the analytics, the per-URL display rules — all of it lives in products-overlay.js, one file on the main site.
main repo: public/assets/products-overlay.js ← substance (UI, analytics, display rules)
each product repo: worker/index.js ← wiring (the code above, zero product-specific content)
The separation pays off on change. Redesigning the button or swapping an ad campaign means updating one file in the main repo; every product picks it up instantly, no redeploys. And since the wiring contains nothing product-specific, it goes into the product template. From then on, shipping a product is enough to ship the support button with it.
The substance side is structured for the ad use case: an array of URL predicates paired with render functions.
// Per-URL rules: ad modals get appended here later
const RULES = [{ test: () => true, render: renderSupportFab }];
“People reading this docs page should see a modal for this book.” That kind of page-level matching gets added at the delivery layer, without touching the product.
The fork problem: manners for deploy glue in an OSS repo
One thing bothered me: some products are public OSS. If worker/index.js sits in the historymap repo, someone who forks it and deploys to their own Cloudflare account gets my Ko-fi button on their screens. Bad manners.
The countermeasure is already in the code above:
if (new URL(request.url).hostname !== 'kenimoto.dev') return response;
A Worker knows which URL it was invoked for, so it injects only when the hostname is kenimoto.dev. Wherever a fork deploys (*.workers.dev or a custom domain), the wiring stays inert, and no external request is ever made. The overlay JS carries the same hostname check as a second layer.
A nice side effect: wrangler dev and workers.dev previews don’t inject either, so the button stays out of the way during development. The README states it plainly: worker/ is deploy glue for the kenimoto.dev deployment, host-gated, and deleting it after forking is recommended.
The trap: a deployed Worker that never runs
I deployed and checked the production URL. No injection.
$ curl -s https://kenimoto.dev/products/historymap/ | grep -c 'products-overlay.js'
0
The response carried cf-cache-status: HIT, so my first theory was stale HTML from the pre-Worker era sitting in the edge cache. I purged the URL and checked again: still 0. Not the cache.
The cause was by design. By default, Workers Static Assets serves asset-matching requests directly, without invoking your Worker script. Even with main pointing at your script. The Worker only runs for requests that do not match an asset. When every page is a static HTML file, as here, the injection code is simply never called.
The fix is one line of config:
"assets": {
"directory": "./dist-worker",
"binding": "ASSETS",
"run_worker_first": true
}
With run_worker_first: true, every request goes through the Worker first, and the injection came alive. You give up the direct-serve edge caching for assets, but HTMLRewriter streams, so I couldn’t feel a difference.
This is a silent failure: deploy succeeds, zero errors, zero executions. If you combine Static Assets with main, make this the first thing you check; it will save you half an hour of staring at curl output.
The guards, summarized
The overlay substance runs four checks at its entry point:
if (window.self !== window.top) return; // never render inside iframe embeds
if (location.hostname !== 'kenimoto.dev') return; // inert on forks and previews
if (!location.pathname.startsWith('/products/')) return;
if (window.__kenProductsOverlay) return; // double-load guard
The first one is specific to this setup: historymap is a tool whose main use case is being embedded in other people’s sites via iframe. A support button floating over a timeline embedded in someone else’s page would be an accident, so it renders only in the top-level window.
Analytics went to GA4. If a product page has no gtag of its own, the overlay bootstraps one, and support clicks fire a support_click event into the same property as the main site. Products owing zero analytics implementation is part of the “template ships the wiring” deal.
Takeaways
- For cross-cutting UI over product screens, inject at the edge instead of wrapping in iframes; don’t pay for it with SEO
- Keep the injection down to one script tag of wiring; concentrate the substance in one file on the main site. Changes propagate to every product instantly, no redeploys
- Host-gate any deploy glue that lives in an OSS repo. Inert on forks, and say so in the README with a deletion recommendation
- Static Assets +
mainwithoutrun_worker_first: truemeans your Worker silently never runs
Shipping a new product now means creating a repo from the template and deploying. The support button and analytics come pre-plugged. Crossing “monetization plumbing” off the someday list is the best thing this architecture bought me.
Was this article helpful?