DEV Community

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

Posted on Edited on

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

Using JSON data filling instead of AI design

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 editing by hand or ask the AI to change one part. It got 562 visitors in its first two days with no ads.

The whole thing runs on a Cloudflare Worker, Supabase and free open models. Here are the parts that turned out more interesting than I expected.

1. Small free models can't design, so I stopped asking them to

The built-in AI runs on open models (GPT-OSS 120B, Llama 3.3 70B) through Workers AI. They write valid HTML, but ask for an "award-winning cinematic website" and you get something different every time: sometimes great, often a plain hero and three cards.

Prompt engineering got me maybe 20% further. What worked was splitting the job:

  • the design is a set of hand-made sections (hero with a CSS 3D object, scroll-lit statement, services, case studies, logo wall…);
  • the model only writes the content, as JSON.
// the model gets a schema with defaults and returns only this
{
  "brand": "Ember", "hue": 28,
  "titleA": "Roasted by fire.", "titleB": "Poured with care.",
  "services": [["Roast", "Small batches on a 1960s drum roaster."], ...],
  "heroArt": "amber neon cube over dark sand dunes, cinematic 3d render"
}
Enter fullscreen mode Exit fullscreen mode

The page is then assembled from the sections. hue rotates the template's accent colours, so a coffee roaster gets amber and an AI lab gets violet. Same model, but the result went from "sometimes nice" to consistently polished, in about 6 seconds.

Lesson: with weak models, move the taste into code and let the model fill in the blanks.

2. Published pages run zero JavaScript

Users publish pages to openconstructor.pw/p/<name>. Since anyone can publish, every page is served with:

Content-Security-Policy: sandbox allow-forms ...; script-src 'none'
Enter fullscreen mode Exit fullscreen mode

No scripts at all. So how do pages still feel alive? Modern CSS covers more than I thought:

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .section {
      animation: rise linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 30%;
    }
  }
}
@keyframes rise { from { opacity: 0; transform: translateY(64px); } }
Enter fullscreen mode Exit fullscreen mode

Scroll-driven animations, perspective + preserve-3d for floating 3D objects, marquees with mask fades: all without a single line of JS. Browsers without support just show the content.

3. Screenshots without a server

The sandbox has a side effect: the page has an opaque origin, so the gallery can't measure or screenshot it in an iframe. Headless Chrome on the server would be costly.

Instead, the editor takes the screenshot itself at publish time:

  1. lay the page out in a hidden iframe to measure its height;
  2. put the HTML into an SVG <foreignObject>;
  3. draw that SVG onto a <canvas> and export JPEG.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
  <foreignObject width="100%" height="100%">${xhtml}</foreignObject></svg>`;
img.src = 'data:image/svg+xml,' + encodeURIComponent(svg);
ctx.drawImage(img, 0, 0);
canvas.toBlob(upload, 'image/jpeg', 0.82);
Enter fullscreen mode Exit fullscreen mode

The worker stores two images in KV: a 1200×630 one for link previews (og:image) and a full-page one for the gallery, which scrolls through it on hover. About 40 KB each.

4. My biggest mistake: a sign-up wall I didn't notice

After launch I looked at the numbers: hundreds of visitors, two accounts. It turned out the free AI required signing in. So most people opened the editor, typed a prompt and hit a login form.

Now visitors get 3 free generations a day without an account (counted in KV per hashed IP, the IP itself isn't stored). An account gives 20 a day, cloud projects and publishing. A friendly offer appears after the first good result, not before.

Lesson: test your product signed out, in a private window, before every launch post.

Try it / read the code

It also exports to React, Vue, Svelte and even Roblox UI and LÖVE 2D. I'd love feedback, especially on where the AI output falls apart.

Top comments (10)

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.

Thread Thread
 
pablo_infante profile image
Pablo Infante •

Per-account scoping is the right call — the global cache-poisoning vector is a nasty one, and you caught it before it existed. That's the better design. Glad it shipped, and genuinely nice work on openconstructor.

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.