DEV Community

Cover image for Building a free AI UI builder on Cloudflare: what small models taught me

Building a free AI UI builder on Cloudflare: what small models taught me

TimofeUS on November 08, 2026

A 2 days ago I launched openconstructor, a free, open-source browser editor: you describe an interface, an AI builds it on a canvas, and you keep e...
Collapse
 
pablo_infante profile image
Pablo Infante •

The zero-JS published pages are my favorite part — serving user-generated pages with script-src 'none' is exactly the right call, and it's wild how far scroll-driven animations + @supports fallbacks get you now. I lean on the same approach: if a thing can be done client-side with no script in the output, the whole class of injection/privacy problems just disappears.

One addition for the gallery screenshot trick: since you're already on a Worker, you could cache the published-time screenshot as an immutable asset keyed by content hash — regenerate only when the page actually changes. Keeps it serverless and the gallery stays cheap.

The "move the taste into code, let the model fill the blanks" lesson is the real gold here though. Saving this one.

Collapse
 
timofeus_248d45d3902a91a3 profile image
TimofeUS •

Thanks! Yes — once script-src 'none' is the rule, a whole category of "what if someone publishes X" questions just goes away. And modern CSS turned out to be enough for almost everything people actually ask for.

Good call on the content hash. Right now the screenshot is stored under the page name and cached for a few minutes, and it's re-rendered on every update, even if nothing visible changed. Hashing the HTML, skipping the upload when the hash matches, and serving it from a URL with the hash in it means it can be cached as immutable, forever. Same cost model, much better cache hits. Adding it to the list.

And agreed on the last point. The biggest jump in quality didn't come from a better prompt or a bigger model. It came from making the model's job smaller.

Collapse
 
pablo_infante profile image
Pablo Infante •

Nice — the re-render-on-every-update is the sneaky cost, since most "updates" don't touch the visual output at all. One refinement if you go the hash route: hash the rendered HTML after you normalize it (strip timestamps, nonces, anything per-request), otherwise you'll get cache misses on bytes that don't actually change the picture. Content-addressed + immutable cache headers and the gallery basically stops paying for screenshots it already has.

Really like that you leaned into CSS instead of fighting the sandbox. Following to see where openconstructor goes.

Thread Thread
 
timofeus_248d45d3902a91a3 profile image
TimofeUS •

Good point, and I checked our pipeline against it. The hash is taken from the HTML the editor exports at publish time. That export is built deterministically from the project, with no timestamps, nonces or random IDs. Everything per-request (the "Made with" badge, og tags, view counting) is injected by the Worker when the page is served, so it never goes into the hash.

The one byte-level miss left: renaming the page changes the

, which changes the hash but not the picture. Cheap enough to live with, but if it ever matters, hashing only the <body> plus styles would fix it.</p> <p>Thanks for following, and for the push to ship it! I'll post the next round of updates here.</p>
Thread Thread
 
pablo_infante profile image
Pablo Infante •

That last one has a clean fix: don't let the page name into the hash at all. Hash only the exported HTML (the deterministic part), store the screenshot under that content hash, and keep a separate name → hash pointer. Rename just repoints; the pixels are already cached under the same hash, so zero regeneration. It also dedupes for free — two projects that export identical HTML share one screenshot.

Sounds like your pipeline was already 90% there. Fun thread — openconstructor's a cool build.

Thread Thread
 
timofeus_248d45d3902a91a3 profile image
TimofeUS •

Shipped it, thanks! The hash was already computed from the exported HTML only, with a slug → hash pointer. The pixels, though, were still stored under the slug. Now they live under the content hash. On publish, the editor first asks "do I already have pictures for this hash?", and if yes, the page is just repointed, so a rename or re-publish costs zero rendering.

One twist: I scoped the dedupe per account rather than globally. With a global store, anyone could publish a page, upload garbage pictures under the hash of a popular template's HTML, and every untouched remix of that template would inherit them. Per account keeps the rename/re-publish win without the cache-poisoning hole.

Collapse
 
maxbuildsdev profile image
Max Kaya •

The screenshot pipeline is neat — SVG foreignObject is exactly the trick that avoids paying for headless Chrome. Two things that bit me with that approach: web fonts don't render inside the SVG unless they're inlined as data URIs in a style block, and any external image means the whole page has to serialize as XHTML (self-closing tags, escaped ampersands) or drawImage quietly gives you a blank. Did you hit either of those, or do the hand-made sections sidestep it by staying self-contained?

On the KV-per-hashed-IP quota: how do you treat shared egress IPs — offices, universities, mobile NAT where a whole block shares one address? I've seen people locked out because a coworker burned the daily limit first. Just eating the false positives at 3/day, or falling back to cookie/localStorage counting for those?

Also curious about the Roblox/LÖVE export — is there a proper intermediate representation between the JSON sections and each target, or is it per-target templates all the way down?

Collapse
 
timofeus_248d45d3902a91a3 profile image
TimofeUS •

Thanks, good questions!

Screenshots: hit both. The page goes through XMLSerializer, so the markup inside the SVG is always valid XHTML. Fonts and images get inlined before rendering: the font CSS (Google / Bunny / Fontshare) is fetched and the @font-face files become data URIs, keeping only the faces whose unicode-range covers the page's text. Remote images become data URIs too, within a size budget. The fonts part was actually added after an earlier comment here pointed it out. The result is stored by content hash per account, so republishing an unchanged page doesn't upload anything.

Shared IPs: honestly, I just accept it. The 3/day anonymous limit is per hashed IP, so on office or campus NAT people share it. The way out is cheap: signing in (free) gives each account its own 20/day, and with your own API key there's no limit at all. I skipped localStorage counting because clearing it takes one click, so it wouldn't really limit anything. One small help: a multi-step request (like web search + answer) counts as one generation, not several.

Exports: there is an intermediate step. Instead of templates per section, the exporter walks the rendered page and reads computed styles into a flat list of nodes: absolute rect, colors, gradient stops, radius, border, text, font, padding, z-order. Roblox (Luau / .rbxmx), LÖVE, React, Vue and Svelte each map that list to their own primitives. So anything you can build in the editor, AI-made or hand-made, exports the same way, and adding a target means writing one mapper.

Collapse
 
avivgerzhue profile image
VibeFoundry •

Moving the taste into code is the right call, and it's why linting a design works better than prompting for one. One thing to watch in a hue-rotated template system: "an AI lab gets violet" is exactly the purple-gradient look people now read as AI-generated, and "hero plus three cards" can sneak back in through the hand-made sections themselves. I hit the same thing with Claude Code and open-sourced unslop, a 72-rule design linter plus studio-style token sets that you can point at exported HTML/CSS: npx -y unslop-mcp@latest audit ./src (repo: github.com/avivgerz-hue/unslop)

Collapse
 
paullovelace profile image
Paul Lovelace •

The 'move the taste into code' lesson is the one I'd steal — hand-made sections, model only fills in the JSON, and suddenly the same weak model produces consistently polished pages. And the sign-up wall story is painfully familiar: hundreds of visitors, two accounts, because the free tier hid behind a login form. Test signed out in a private window before every launch post — I learned that one the expensive way too. One question: the foreignObject screenshot trick — does it hold up on pages with web fonts, or do the fonts fall back in the export?

Collapse
 
timofeus_248d45d3902a91a3 profile image
TimofeUS •

Thanks! "Test signed out in a private window" is going on a sticky note above my monitor.

Good question, and it made me fix it. Until today they didn't hold up. The page is drawn through an SVG as an image, and an SVG image can't fetch anything, so web fonts fell back to system fonts and remote images came out blank. Now, before drawing, the editor fetches the font CSS from Google Fonts, Bunny or Fontshare. It keeps only the @font-face subsets whose unicode-range covers characters actually on the page, and inlines those woff2 files as data: URIs. Remote s get the same treatment, within a 3 MB budget. Anything without CORS still falls back, but the common case of Google Fonts plus Unsplash photos now renders exactly, in under a second.

Collapse
 
devkursat profile image
Kürşat Yılmaz •

The obsession with wrapping remote APIs into multi-layered agent abstractions is creating massive hidden latency and failure cascades in production.

If you profile real-world workloads, bare-metal local inference (via direct C++ / llama.cpp or vLLM bindings) consistently beats fragile cloud wrappers on both predictability and cost. Tooling should eliminate abstraction penalties, not compound them.