<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Innocent Oyebode</title>
    <description>The latest articles on DEV Community by Innocent Oyebode (@innocodes).</description>
    <link>https://dev.to/innocodes</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F931508%2F4a990852-f856-4238-ac27-9b12bf996a40.jpg</url>
      <title>DEV Community: Innocent Oyebode</title>
      <link>https://dev.to/innocodes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/innocodes"/>
    <language>en</language>
    <item>
      <title>One Support Call, Four Bugs: A Postmortem on Our AI Website Builder</title>
      <dc:creator>Innocent Oyebode</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/innocodes/one-support-call-four-bugs-a-postmortem-on-our-ai-website-builder-4hcm</link>
      <guid>https://dev.to/innocodes/one-support-call-four-bugs-a-postmortem-on-our-ai-website-builder-4hcm</guid>
      <description>&lt;p&gt;I discovered a page on our customer's site that, at first glance, appeared to be a CSS bug. A hero section on her site had collapsed into a vertical sliver — a fashion-photo strip about 70 pixels wide, running the full height of the page, with her headline text wrapping into a column one word deep. Below it, the same broken hero repeated itself. The service cards that should have been on the page were gone.&lt;/p&gt;

&lt;p&gt;It's the kind of screenshot that makes you assume you know the fix before you've looked at anything. A missing breakpoint, probably. Ship a CSS patch, close the ticket, move on.&lt;/p&gt;

&lt;p&gt;I was wrong about the scope in almost every direction. By the time I was done, I'd found four separate bugs, two of them in completely different parts of the stack, and one of them entirely unrelated to the customer's report — a bug in the fix itself. This is a write-up of what actually happened, in the order I found it, because the order matters: each bug looked like the whole story until the next one showed up underneath it.&lt;/p&gt;

&lt;p&gt;WebDigitize is an AI website builder for Nigerian small businesses — describe your business, get a live, editable, multi-page site in minutes, built on a drag-and-drop editor (&lt;a href="https://github.com/measuredco/puck" rel="noopener noreferrer"&gt;Puck&lt;/a&gt;) with an AI layer on top. The incident touched the editor, the CSS layout system, the AI onboarding pipeline, and — in the least expected turn — the database ORM.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Saw
&lt;/h2&gt;

&lt;p&gt;The customer had onboarded as a business selling physical products, added a handful of items to her catalog, and picked a general-purpose template rather than one of the commerce-flavored ones (it was the best fit for how she described her business). The site generated fine. Interestingly, I launched WebDigitize the previous day, so I was checking to see if there were new onboards. I saw her website on the dashboard and decided to check out each page. That was when I discovered the issue. I followed up with a call, and she confirmed exactly what happened. She went looking for her products on the live site and couldn't find them anywhere — no shop page, no product grid, nothing. So she opened the editor on her phone and started poking at the Services page, trying to see if the products were hiding in a section she hadn't looked at.&lt;/p&gt;

&lt;p&gt;That's where the sliver came from. The page's hero component — a two-column "text on the left, full-height photo on the right" layout — had been dragged, on a touchscreen, into a three-column grid meant for service cards. A hero designed to occupy the full page width was now confined to one-third of it, and its internal CSS (a &lt;code&gt;7fr / 5fr&lt;/code&gt; grid split) rendered that third into two even-narrower slivers. The section had also been duplicated somewhere in the process, which is why I saw it twice.&lt;/p&gt;

&lt;p&gt;The obvious read: "customer made a drag-and-drop mistake on mobile, CSS broke because nobody designed for a hero being squeezed that small." Both true. Also, not remotely the full story.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug One: A Split Hero With No Fallback For Its Own Container
&lt;/h2&gt;

&lt;p&gt;The hero component's CSS looked like this before I touched it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero-split&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;899px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-split&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A perfectly reasonable component, built around one assumption: the only thing that would ever make this hero narrow is a narrow &lt;em&gt;viewport&lt;/em&gt;. On a phone, stack the columns. On a desktop, split them 7:5. That assumption is true right up until someone drops the component somewhere that isn't the full page width — inside a grid column, a sidebar, anywhere the component's rendered width is decoupled from the browser's.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@media&lt;/code&gt; queries answer "how wide is the viewport?" They cannot answer "how wide is &lt;em&gt;this component, right now, wherever it's been placed&lt;/em&gt;?" That second question is what a component with an internal responsive layout actually needs answered, and for a long time, CSS had no good way to ask it.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;strong&gt;container query&lt;/strong&gt; — CSS that responds to an element's own size rather than the viewport's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero-section&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;container-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@container&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;899px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-split&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same breakpoint, same fallback layout — the difference is &lt;em&gt;what's being measured&lt;/em&gt;. Now, if this hero ever ends up somewhere narrow, for any reason, at any nesting depth, it stacks gracefully instead of dividing an already-small box into two useless columns. We didn't design this fix for "what if someone drags a hero into a grid cell" specifically — we designed it for "this component should never assume it owns the full viewport," which is the more durable version of the same idea. The next unanticipated place a hero ends up will handle itself the same way.&lt;/p&gt;

&lt;p&gt;If you maintain any component library with a component that has an internal responsive layout, this is worth auditing today: every &lt;code&gt;@media&lt;/code&gt;-based internal layout in a component that could ever be nested is a latent version of this bug, just waiting for someone to place the component somewhere you didn't design for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug Two: The Editor Let Her Do This At All
&lt;/h2&gt;

&lt;p&gt;The CSS fix makes the &lt;em&gt;outcome&lt;/em&gt; of a hero-in-a-grid drop survivable. It doesn't answer the more basic question: should our drag-and-drop editor have allowed that drop in the first place?&lt;/p&gt;

&lt;p&gt;No. A full-width hero, a call-to-action banner, a page section — these are page-level compositional elements. They don't have a sensible meaning inside a three-column card grid, any more than it makes sense to put a &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; tag inside a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;. The editor had no opinion on this; every block type was a valid child of every container type, and Puck's &lt;code&gt;DropZone&lt;/code&gt; component happily accepted the drag.&lt;/p&gt;

&lt;p&gt;Puck (and most drag-and-drop editors) probably support &lt;code&gt;allow&lt;/code&gt;/&lt;code&gt;disallow&lt;/code&gt; lists per drop zone for exactly this reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_LEVEL_ONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Section&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EditorialHero&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CTABanner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;StatStrip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DropZone&lt;/span&gt; &lt;span class="na"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"content"&lt;/span&gt; &lt;span class="na"&gt;disallow&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;PAGE_LEVEL_ONLY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;Section&lt;/code&gt;/&lt;code&gt;Grid&lt;/code&gt;/&lt;code&gt;Flex&lt;/code&gt; container's drop zone in our editor now rejects those block types outright. If you drag a hero toward a grid cell, it simply won't drop there — no error message needed, because the interaction itself now communicates the constraint. This is the general lesson: &lt;strong&gt;a structural invariant your data model relies on ("heroes live at the page level") should be enforced by the tool that produces the data, not documented and hoped for.&lt;/strong&gt; I'd implicitly assumed nobody would ever nest a hero in a grid, which was true until the first person who tried.&lt;/p&gt;

&lt;p&gt;There's a second layer here, though, that the CSS fix and the drop-zone fix both dodge: this specific mis-drop happened on a phone. Precision drag-and-drop — picking up a small element, dragging it across a cramped viewport, dropping it into the correct one of several adjacent, similarly-sized targets — is a genuinely hard interaction on a touchscreen, even with every guardrail we can add. We could keep discovering edge cases in what happens when a block-based editor gets used with a thumb on a 6-inch screen, or we could accept that this class of editor isn't a good mobile interaction at all and say so plainly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isTooNarrow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMediaQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(max-width: 767px)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isTooNarrow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OpenOnDesktopMessage&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The editor now blocks editing below a viewport-width threshold with a message pointing the owner at a larger screen — your site stays exactly as it is; you just can't drag things around from a phone. This isn't a workaround for the bug we just fixed; it's an acknowledgement that some tools have a form factor they're honestly built for, and pretending otherwise generates support tickets forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug Three: The Products That Were Never There
&lt;/h2&gt;

&lt;p&gt;Here's where the incident stopped being a UI bug and became something more interesting — and where I have to admit our first fix was aimed at a symptom.&lt;/p&gt;

&lt;p&gt;Reread the timeline: the customer didn't set out to break the Services page. She was hunting for products that had never appeared on her site &lt;em&gt;at all&lt;/em&gt;, from the moment it was generated, even before she ever touched the editor. The drag-and-drop mishap was a downstream consequence of a completely separate failure: &lt;strong&gt;her site was never given anywhere to sell products in the first place.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our generation pipeline has two paths. The original path calls the AI to generate an entire page's structure from scratch — and it has always included a check: does this business sell products? If yes, does the generated site actually contain a page to sell them on? If not, build one deterministically (a heading plus a full-catalog product grid, no AI needed for something this mechanical) and wire it into navigation.&lt;/p&gt;

&lt;p&gt;The second path is newer and exists for cost and speed: rather than asking the AI to invent a page structure from nothing, I clone one of a set of hand-designed "flagship" templates (a fashion boutique layout, a restaurant layout, a general-purpose services layout) and only ask the AI to fill in twenty or thirty text slots — five to ten times cheaper than full generation, and the design quality of a human-built template rather than an LLM's best guess. This is the path our customer went through.&lt;/p&gt;

&lt;p&gt;The bug: &lt;strong&gt;the "does this business sell products, and if so, does the site have somewhere to sell them" check lived entirely in the first path. The second path never inherited it.&lt;/strong&gt; Some flagship templates (fashion, food) do include commerce blocks by design. Others — general, services, events, creative — don't, because they're built for businesses that don't sell physical products. Nothing in the pipeline cross-checked "this owner said she sells products" against "this template has zero commerce blocks." Two individually reasonable systems — a store-detection check built for one generation path, a template library built for a different one — had simply never been asked to talk to each other, because the second path didn't exist yet when the first check was written.&lt;/p&gt;

&lt;p&gt;Concretely, in pseudocode, the gap looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_site&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Fast path: clone template, AI fills text slots. No commerce check.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;generate_from_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Slow path: full AI generation, WITH a commerce check.
&lt;/span&gt;        &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_full_ai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sells_products&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;has_shop_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;add_shop_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix moves that check to the one place both paths already pass through — right before either result is saved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_site&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_from_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_full_ai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sells_products&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;has_shop_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;add_shop_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line moved from inside a branch to after both branches. The lesson generalizes past this one bug: &lt;strong&gt;a check that depends on a cross-cutting fact about the business ("does this business sell things?") needs to live at the single point where all content-generation paths converge, not be duplicated — or worse, present in only one of them — per path.&lt;/strong&gt; Every time you add a new way to produce the same kind of output, audit which invariants the old paths guaranteed and confirm the new one guarantees them too. It's an easy thing to miss precisely because both paths were separately correct in isolation; the bug only exists in the gap between them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug Four: Fixing Production Broke My Fix First
&lt;/h2&gt;

&lt;p&gt;With all three root causes understood, the actual data repair was almost the easy part — pull the mangled hero back out to the page level, drop the duplicate, remove the sections the mishap had gutted, and add the shop page the customer should have had from day one. I wrote it as a script, ran it against a copy of the affected data locally, watched it produce exactly the output we expected, and handed off the command to run in production.&lt;/p&gt;

&lt;p&gt;It failed immediately, with a stack trace from deep inside SQLAlchemy that had nothing to do with any of the logic I'd written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlalchemy.exc.InvalidRequestError: When initializing mapper Mapper[User(users)],
expression 'BusinessPaymentAccount' failed to locate a name ('BusinessPaymentAccount').
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script imported exactly two model classes — the &lt;code&gt;Site&lt;/code&gt; and &lt;code&gt;Product&lt;/code&gt; tables it actually needed. That turned out to be the whole problem. SQLAlchemy declares relationships between models by string, so they can reference each other without circular Python imports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payment_account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BusinessPaymentAccount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BusinessPaymentAccount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;back_populates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string only resolves if &lt;code&gt;BusinessPaymentAccount&lt;/code&gt; has actually been imported &lt;em&gt;somewhere&lt;/em&gt; in the running process — not used, just imported, so the class exists in SQLAlchemy's shared mapper registry. And crucially, the first time &lt;em&gt;any&lt;/em&gt; query runs, SQLAlchemy configures &lt;strong&gt;every&lt;/strong&gt; mapper it knows about, not just the one you queried — because relationships can point in any direction, it has to make sure the whole graph resolves before it trusts any of it. The script imported &lt;code&gt;Site&lt;/code&gt; and &lt;code&gt;Product&lt;/code&gt;. &lt;code&gt;Site&lt;/code&gt; has a foreign key relationship into &lt;code&gt;User&lt;/code&gt;. &lt;code&gt;User&lt;/code&gt; has a relationship string-pointing at &lt;code&gt;BusinessPaymentAccount&lt;/code&gt;, which nothing in my script had ever imported. First query, mapper configuration walks the whole graph, hits a name it's never heard of, throws.&lt;/p&gt;

&lt;p&gt;The live FastAPI application never hits this because one large router module happens to import nearly every model in the app across its many endpoints — by the time any request is served, every relationship target has been imported as a side effect of something completely unrelated. That's not a design decision anyone made on purpose; it's an accident of the app being big enough that everything gets pulled in eventually. A small, standalone script doesn't get that for free — it only has what it explicitly imports.&lt;/p&gt;

&lt;p&gt;Once I knew what to look for, I found the same latent bug already sitting in the Alembic migration environment, which manually lists model imports for exactly this reason — and the list was stale, missing four models added since it was last touched. It hadn't broken yet, only because no migration had happened to touch a relationship pointing at one of the missing four. That's the uncomfortable part of this class of bug: it's not "wrong," it's "correct until the next person changes something unrelated."&lt;/p&gt;

&lt;p&gt;The fix, instead of hand-maintaining another list that will drift the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;importlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pkgutil&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app.models&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;models_pkg&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;import_all_models&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;mod&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pkgutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_modules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models_pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__path__&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;importlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;import_module&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;models_pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it once, before any query, in any standalone script. It doesn't need to know which models matter — it imports the whole package, every time, so the mapper registry is always complete regardless of which relationships happen to exist this month. The lesson: &lt;strong&gt;if correctness depends on "everything got imported somewhere," make that automatic and exhaustive, not a manually maintained list.&lt;/strong&gt; A list like that isn't wrong when it's written; it's a countdown to the next omission.&lt;/p&gt;




&lt;h2&gt;
  
  
  Writing a Repair Script You Can Trust
&lt;/h2&gt;

&lt;p&gt;One more thing worth calling out, because it's a pattern I'd recommend for any one-off production data fix: I wrote the repair logic as a pure function — data in, corrected data out, no database access inside it at all — and wrote a test &lt;em&gt;first&lt;/em&gt;, hand-constructing a data structure that reproduced the exact damage (hero in a grid, duplicated section, orphaned empty containers) before writing a single line of the fix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;repair_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;puck_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Pure transform: returns (fixed_data, human_readable_log). No I/O.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_repairs_hero_dragged_into_grid&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;broken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_known_broken_fixture&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;repair_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EditorialHero&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# hoisted back to top level
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="c1"&gt;# running it again should be a no-op
&lt;/span&gt;    &lt;span class="n"&gt;again&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;repair_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;again&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;log2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last assertion — repair twice, expect the second run to do nothing — caught a real bug on the first attempt (an empty container removal wasn't marking its parent section as needing re-evaluation, so a heading-only section survived one pass and was only caught on a second). Cheap to check, and it meant I trusted the script's idempotency before it ever touched a live row. The database-facing wrapper around this pure function only had two responsibilities: fetch the record, and — behind an explicit &lt;code&gt;--apply&lt;/code&gt; flag, off by default — write it back. Every run without that flag prints exactly what &lt;em&gt;would&lt;/em&gt; change and touches nothing. For a script that runs once, against production data, days after the incident that necessitated it, that's not overkill.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS&lt;/strong&gt;: the split-hero layout responds to its own rendered width via a container query, with the viewport-based media query kept as a belt-and-suspenders fallback for browsers without container query support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editor&lt;/strong&gt;: page-level blocks (heroes, CTA banners, stat strips) are now disallowed as children of Section/Grid/Flex drop zones — the mis-drop can't happen again, on any device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editor, mobile&lt;/strong&gt;: drag-and-drop editing is blocked below a viewport-width threshold, with a plain-language message pointing the owner to a larger screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation pipeline&lt;/strong&gt;: the "does this business sell products, and does the site have somewhere to sell them" check now runs after both generation paths, not inside only one of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tooling&lt;/strong&gt;: a reusable "import every model module" helper for standalone scripts, so the SQLAlchemy mapper-registry gap can't silently resurface in the next one-off script — or, we hope, in the Alembic environment that had the same latent issue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data&lt;/strong&gt;: the customer's affected page was repaired, and her shop page was added via a pure-function, test-first, dry-run-by-default script.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A component with an internal responsive layout should respond to its own rendered size, not the viewport.&lt;/strong&gt; &lt;code&gt;@media&lt;/code&gt; answers the wrong question the moment a component can be nested anywhere other than the page's full width; container queries answer the right one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce structural invariants in the tool, not in documentation or hope.&lt;/strong&gt; If your data model assumes X can never contain Y, make the editor refuse that drop rather than discovering the assumption was implicit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some interactions don't degrade gracefully to a smaller form factor — say so explicitly.&lt;/strong&gt; Blocking drag-and-drop editing on a phone, with a clear message, beats an editor that technically "works" on mobile but produces this class of damage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cross-cutting business rule needs one home, at the point where all paths converge — not one copy per code path.&lt;/strong&gt; Every time you add a new way to produce an existing kind of output, audit whether it inherited every invariant the old paths guaranteed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If correctness depends on "everything got imported somewhere," make that automatic, not a hand-maintained list.&lt;/strong&gt; Manually maintained import lists (or any similar "remember to register X" pattern) are correct when written and wrong on a schedule you don't control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write one-off production data repairs as pure functions, test-first, against a fixture that reproduces the actual damage — and make them idempotent and dry-run-by-default.&lt;/strong&gt; The five extra minutes this takes is cheaper than a second incident caused by the fix for the first one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these four bugs would have been caught by looking only at the screenshot we started with. Each one only became visible by asking "why did this actually happen", one layer deeper than the last — and the layer that mattered most to the customer (her missing products) was the one furthest from what she'd actually reported.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://webdigitize.com" rel="noopener noreferrer"&gt;WebDigitize is live&lt;/a&gt;. If you're building on top of a drag-and-drop editor or running an AI generation pipeline in production, I'd be curious what invariant-enforcement patterns you've settled on — drop a comment below.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>css</category>
      <category>debugging</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Lessons from Embedding an LLM Inside a Drag-and-Drop Editor</title>
      <dc:creator>Innocent Oyebode</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/innocodes/lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor-apm</link>
      <guid>https://dev.to/innocodes/lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor-apm</guid>
      <description>&lt;p&gt;When you integrate an AI generation step into a drag-and-drop editor, you face a problem that is rarely discussed in LLM tutorials: &lt;strong&gt;the model's output must be structurally valid, not just semantically good&lt;/strong&gt;. A hallucinated CSS class name is a style regression. A hallucinated prop key in an editor schema is a broken component.&lt;/p&gt;

&lt;p&gt;This is a writeup of what I learned building WebDigitize, an AI-powered website generator and editor for Nigerian small businesses, using &lt;a href="https://github.com/measuredco/puck" rel="noopener noreferrer"&gt;Puck&lt;/a&gt; as the editor layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Puck Expects
&lt;/h2&gt;

&lt;p&gt;Puck stores page state as a JSON document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"root"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"props"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HeroSection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"props"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"headline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fresh Bread, Delivered Daily in Lagos"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"zones"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry in &lt;code&gt;content&lt;/code&gt; maps to a React component registered in Puck's config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;components&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;HeroSection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;subheadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;ctaLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;ctaHref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subheadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctaLabel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctaHref&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;subheadline&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ctaHref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ctaLabel&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical insight: &lt;strong&gt;Puck only renders props defined in &lt;code&gt;fields&lt;/code&gt;&lt;/strong&gt;. If the AI emits &lt;code&gt;headlineText&lt;/code&gt; instead of &lt;code&gt;headline&lt;/code&gt;, the component renders silently with &lt;code&gt;undefined&lt;/code&gt;. No error. No warning. An invisible field.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Schema Representation Problem
&lt;/h2&gt;

&lt;p&gt;Your LLM knows nothing about your Puck config. You need to tell it what is valid. The naive approach is to describe your components in prose:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The HeroSection component takes a headline, a subheadline, a CTA label, and a CTA href."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This works for simple cases and fails for complex ones. If you have 15 components, each with 4–8 fields, prose descriptions become ambiguous, and the model drifts toward invented field names that sound plausible.&lt;/p&gt;

&lt;p&gt;The approach that worked: &lt;strong&gt;represent your component schemas as a compact formal notation in the system prompt&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== PUCK BLOCK SCHEMAS (STRICT) ===
Only use the exact prop keys listed. No extra keys. No variations.

HeroSection:
  headline: string           (max 60 chars — punchy, specific to this business)
  subheadline: string        (max 120 chars)
  ctaLabel: string           (max 30 chars)
  ctaHref: string            (WhatsApp link preferred; format: https://wa.me/{number})

ServicesSection:
  title: string
  services: Array&amp;lt;{
    icon: "star" | "check" | "bolt" | "heart" | "shield" | "phone"
    title: string            (max 40 chars)
    description: string      (max 100 chars)
  }&amp;gt;                         (2–4 items, no more)

ContactSection:
  address: string
  phone: string              (must match brief — do not invent)
  hours: string              (format: "Mon–Sat, 8am–6pm")
  mapEmbedQuery: string      (Google Maps query string, e.g. "Chicken Republic Ikeja Lagos")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key decisions in this notation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pipe-separated enums (&lt;code&gt;"star" | "check" | ...&lt;/code&gt;) are extremely effective — the model treats them as a closed set.&lt;/li&gt;
&lt;li&gt;Inline constraints (&lt;code&gt;max 60 chars&lt;/code&gt;) prevent verbose fields that break layouts.&lt;/li&gt;
&lt;li&gt;Explicit notes (&lt;code&gt;must match brief — do not invent&lt;/code&gt;) address the most common semantic failures.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  System Prompt vs User Message
&lt;/h2&gt;

&lt;p&gt;I spent some time debugging why the critic pass kept ignoring my schema constraints. The answer was placement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema in the user message:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;System&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="sx"&gt;You&lt;/span&gt; are a web design assistant. Output only valid JSON.
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;User&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;   &lt;span class="sx"&gt;Generate&lt;/span&gt; a home page for Mama Titi's Kitchen.
          Here are the allowed block schemas: ...
          Business details: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In multi-turn conversations (generation → critic → refinement), the schema in the user message is treated as part of the task context that can be "overridden" by subsequent turns. The critic saw new information in its turn and occasionally invented prop names that were not in the original schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema in the system prompt:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;System&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="sx"&gt;You&lt;/span&gt; are a web design assistant. Output only valid JSON.
          === PUCK BLOCK SCHEMAS (STRICT) ===
          HeroSection: ...
          [all schemas]
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;User&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;   &lt;span class="sx"&gt;Generate&lt;/span&gt; a home page for Mama Titi's Kitchen.
          Business details: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system prompt is treated as a persistent context across all turns. Schema violations dropped to nearly zero. The model seems to correctly understand that system prompt constraints are inviolable while user message constraints can flex.&lt;/p&gt;




&lt;h2&gt;
  
  
  Validating Output at the Boundary
&lt;/h2&gt;

&lt;p&gt;Even with strict prompting, I added a validation step before storing any generated content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ALLOWED_BLOCKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HeroSection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subheadline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ctaLabel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ctaHref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ServicesSection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ContactSection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mapEmbedQuery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_puck_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="n"&gt;block_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;block_type&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED_BLOCKS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown block type: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;block_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;allowed_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ALLOWED_BLOCKS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;block_type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;props&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;allowed_keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;block_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: unknown prop &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When validation fails, I log the raw output and retry generation with an amended prompt that includes the specific error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;additional_constraint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRITICAL: Previous attempt used invalid props: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Fix these.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generate_with_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra_constraint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;additional_constraint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, the retry only triggers for about 2% of generations.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Array Prop Problem
&lt;/h2&gt;

&lt;p&gt;Arrays are the most reliably broken output format. Consider &lt;code&gt;services: Array&amp;lt;{icon, title, description}&amp;gt;&lt;/code&gt;. The model produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"services"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"icon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"star"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fresh Bread"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Baked daily"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"icon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"check"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fast Delivery"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Within 2 hours"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is valid. But then on a retry or critic pass, I have seen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"services"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"services"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"icon_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"star"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"service_title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fresh Bread"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Baked daily"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second failure (renamed keys inside array items) is invisible without deep validation. I extended the validator to recurse into array items:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SERVICES_ITEM_KEYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_services&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SERVICES_ITEM_KEYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]: unknown key &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Rich Text: The Unexpected Footgun
&lt;/h2&gt;

&lt;p&gt;One of the blocks — a &lt;code&gt;ContentBlock&lt;/code&gt; for long-form text — accepted a &lt;code&gt;body&lt;/code&gt; field that was supposed to be a Puck rich text value (a Lexical editor serialised state, not a plain string).&lt;/p&gt;

&lt;p&gt;I made the mistake of exposing this to the AI. The model produced beautiful-looking text, but it was a plain string. The Lexical editor expected an object. The result was a crash on the editor load that only appeared when the user tried to edit the block.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;never ask an LLM to produce rich text editor state&lt;/strong&gt;. Instead, the &lt;code&gt;ContentBlock&lt;/code&gt; accepts a &lt;code&gt;bodyText: string&lt;/code&gt; prop. A &lt;code&gt;useEffect&lt;/code&gt; in the component converts it to a Lexical initial state on first render. The AI never touches the editor's internal format.&lt;/p&gt;




&lt;h2&gt;
  
  
  Structuring the Puck Config for AI-Friendliness
&lt;/h2&gt;

&lt;p&gt;After building this, I restructured the Puck config with AI consumers in mind:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use consistent naming conventions.&lt;/strong&gt; All text fields are &lt;code&gt;string&lt;/code&gt; types named with a noun (&lt;code&gt;headline&lt;/code&gt;, &lt;code&gt;subheadline&lt;/code&gt;, &lt;code&gt;address&lt;/code&gt;). All boolean toggles start with &lt;code&gt;show&lt;/code&gt; (&lt;code&gt;showCta&lt;/code&gt;, &lt;code&gt;showImage&lt;/code&gt;). The model learned these conventions quickly and stopped inventing alternatives.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep array items flat.&lt;/strong&gt; Nested object arrays are reliable. Arrays of arrays are not. If you need complex nested structures, flatten them at the schema layer.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Provide default prop values in the component.&lt;/strong&gt; If &lt;code&gt;ctaHref&lt;/code&gt; is missing, the component should gracefully render without a CTA link rather than throwing. This makes partial AI output survivable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Separate "AI-generated" fields from "user-configured" fields.&lt;/strong&gt; Fields like &lt;code&gt;backgroundImage&lt;/code&gt; and &lt;code&gt;colorScheme&lt;/code&gt; are set by the user through the visual editor, never by the AI. Keeping these out of the generation schema prevents the model from guessing colours or image URLs, which it consistently does poorly.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Rendering Architecture
&lt;/h2&gt;

&lt;p&gt;Puck renders in two modes in WebDigitize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Editor mode&lt;/strong&gt; (&lt;code&gt;&amp;lt;Puck config={config} data={data} /&amp;gt;&lt;/code&gt;) — used in the dashboard, full interactive editing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render mode&lt;/strong&gt; (&lt;code&gt;&amp;lt;Render config={config} data={data} /&amp;gt;&lt;/code&gt;) — used on public-facing site pages, no editing chrome&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both modes use the same &lt;code&gt;config&lt;/code&gt; and &lt;code&gt;data&lt;/code&gt;. When the AI generates content and stores it, it is immediately renderable on the public site. When the user opens the editor, they see the AI output in the Puck canvas, ready to drag, drop, and edit.&lt;/p&gt;

&lt;p&gt;This single-schema-serves-both-modes design is the most important architectural decision. It means the AI, the editor, and the public renderer are all working with the same contract.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Formal schema notation in the system prompt outperforms prose descriptions&lt;/strong&gt; for structured output tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System prompt beats user message for persistent constraints&lt;/strong&gt; across multi-turn generation pipelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate at the boundary, not at render time.&lt;/strong&gt; Silent failures are the enemy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never send rich text editor state formats to an LLM.&lt;/strong&gt; Accept plain strings and convert at the component layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design your block schemas with AI consumers in mind&lt;/strong&gt; from the start — consistent conventions, flat structures, clear defaults.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Have you run into schema validation problems with LLM output in your own projects? I'd love to hear how you solved it.&lt;/p&gt;




</description>
      <category>nextjs</category>
      <category>fastapi</category>
      <category>promptengineering</category>
      <category>startup</category>
    </item>
    <item>
      <title>How I Built a Multi-Page AI Website Generator for Nigerian SMBs — Architecture, LLM Prompting, and Lessons Learned</title>
      <dc:creator>Innocent Oyebode</dc:creator>
      <pubDate>Wed, 15 Jul 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/innocodes/how-i-built-a-multi-page-ai-website-generator-for-nigerian-smbs-architecture-llm-prompting-and-4pb5</link>
      <guid>https://dev.to/innocodes/how-i-built-a-multi-page-ai-website-generator-for-nigerian-smbs-architecture-llm-prompting-and-4pb5</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Most Nigerian small businesses have no web presence at all. When they do get a website, it is usually a stale brochure-ware page that took a freelancer three weeks to deliver and costs ₦150,000 they could not really afford. The freelancer is long gone; the business owner cannot update a word.&lt;/p&gt;

&lt;p&gt;The conventional "website builder" alternatives — Wix, Squarespace, GoDaddy — are not built for Nigeria. Payment integration means Stripe (not available to Nigerian merchants directly). Domain registration defaults to USD. The templates look like they belong in Manchester, not Maiduguri. Even if the interface were perfect, a bakery owner in Lagos should not need to understand the difference between a hero section and a call-to-action button to get a professional website.&lt;/p&gt;

&lt;p&gt;I built WebDigitize to solve this end-to-end: a platform where a Nigerian business owner fills in a short onboarding form — business name, type, brief description, preferred visual style, phone, city — and receives a complete, multi-page, styled website with a live subdomain and e-commerce capability within minutes.&lt;/p&gt;

&lt;p&gt;The hard part is step one: taking those five fields and producing a website that looks like a human designed it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture at a Glance
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Next.js 15 (App Router)  ←→  FastAPI (Python)  ←→  PostgreSQL (Neon)
                                     ↓
                             Anthropic Claude API
                                     ↓
                          Background task queue (asyncio)
                                     ↓
                             Puck JSON stored in DB
                                     ↓
                    Cloudflare for SaaS (custom domains)
                          Cloudflare R2 (media)
                             Paystack (billing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core website builder is &lt;a href="https://github.com/measuredco/puck" rel="noopener noreferrer"&gt;Puck&lt;/a&gt; — an open-source React drag-and-drop editor. Puck stores page content as a JSON document describing a tree of typed blocks (&lt;code&gt;HeroSection&lt;/code&gt;, &lt;code&gt;FeaturesGrid&lt;/code&gt;, &lt;code&gt;TestimonialCard&lt;/code&gt;, etc.). The AI's job is to generate that JSON document, filled with content that makes sense for the specific business.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Generation Pipeline
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Business Brief
&lt;/h3&gt;

&lt;p&gt;The onboarding wizard collects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;BusinessBrief&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;businessName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;businessType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// "Restaurant", "Law firm", "Tech startup", etc.&lt;/span&gt;
  &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// free-text, up to ~300 chars&lt;/span&gt;
  &lt;span class="nl"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;modern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;classic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;minimal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;whatsapp&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;instagram&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;services&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;        &lt;span class="c1"&gt;// optional structured list&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;style&lt;/code&gt; field maps to a concrete design token set — font pairings, border radii, colour palette — so the AI never needs to reason about hex codes. It picks from four named personalities; the platform fills in the rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Home Page Generation (Always Async)
&lt;/h3&gt;

&lt;p&gt;Generation happens in a FastAPI background task so the HTTP response returns immediately and the user sees a progress screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/onboarding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;complete_onboarding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnboardingRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundTasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_site&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generate_site_content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;generating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;site_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generation function calls Claude with a carefully structured system prompt and returns a Puck JSON document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_home_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BusinessBrief&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
You are a professional web designer generating page content for Nigerian businesses.
Output ONLY valid JSON conforming to the Puck content schema.
Never include placeholder text like &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lorem ipsum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.
All phone numbers must use the Nigerian format provided.
Business hours should reflect typical Nigerian business culture (Mon-Sat, 8am-6pm).
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;user_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Generate a home page for:
Business name: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
Type: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
Description: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
City: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
Phone: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
Style preference: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

The page must include (in order):
1. HeroSection — compelling headline + one-sentence subheadline, no generic filler
2. ServicesSection — 3–4 services inferred from the business type and description
3. WhyUsSection — 3 trust signals realistic for this business category
4. TestimonialsSection — 2 synthetic but realistic-sounding customer testimonials
5. CTASection — contact or WhatsApp prompt
6. ContactSection — address (city), phone, business hours

Output the full Puck JSON object only. No explanation.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;anthropic_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_message&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: The Design Critic Pass
&lt;/h3&gt;

&lt;p&gt;Raw LLM output can be technically valid JSON but weak content — headlines that are too generic, services that do not match the business type, testimonials that read as obviously fake. After the first generation pass, a second prompt acts as a critic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;critic_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BusinessBrief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;critique_prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Review this website draft for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;).

Check for:
1. Headlines that are too generic (&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome to our website&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;) — rewrite them to be specific
2. Services that don&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t match the business — fix or remove
3. Testimonials that sound fake or unnatural — make them read like real Nigerian customers
4. Any western cultural assumptions (e.g., &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Schedule a meeting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; for a street-food business) — localise
5. Missing local context (area of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, local terminology)

Return the improved Puck JSON. If the draft is already strong, return it unchanged.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;anthropic_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;critique_prompt&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Improve the draft based on the criteria above. Return only JSON.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This two-pass approach consistently produces noticeably better output than a single long prompt, at the cost of one extra API call per page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Sub-Page Generation (Paid Plans)
&lt;/h3&gt;

&lt;p&gt;Growth and Pro plan customers get full multi-page sites. After the home page is stored, additional pages are generated in parallel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_subpages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BusinessBrief&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pages_to_generate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;about&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;generate_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pages_to_generate&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_exceptions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages_to_generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;store_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each page type has its own prompt, but they share the same critic pass. The &lt;code&gt;about&lt;/code&gt; page prompt, for example, explicitly asks for the origin story implied by the business description, not a generic "About Us" template.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The Shop Page (Deterministic, No AI)
&lt;/h3&gt;

&lt;p&gt;The e-commerce shop page is rendered entirely from structured product data in the database — no LLM involved. This was a deliberate decision: the shop layout needs to be perfectly predictable, support filtering and pagination, and respond to real-time inventory. Asking an LLM to generate a page that also needs to execute logic is a footgun.&lt;/p&gt;

&lt;p&gt;Instead, the shop is a fixed Puck-compatible layout component (&lt;code&gt;ShopPage&lt;/code&gt;) that is always rendered in position regardless of what the AI generated. The block receives a &lt;code&gt;siteId&lt;/code&gt; prop and fetches live product data client-side.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Puck JSON Schema Problem
&lt;/h2&gt;

&lt;p&gt;The hardest part of this project was not prompting — it was schema design.&lt;/p&gt;

&lt;p&gt;Puck's content format looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"root"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"props"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HeroSection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"props"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"headline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fresh Bread, Delivered Daily"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"subheadline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Abuja's favourite artisan bakery since 2018"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ctaLabel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order via WhatsApp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ctaHref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://wa.me/2348012345678"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"zones"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each block type has its own &lt;code&gt;props&lt;/code&gt; shape. The LLM must produce valid prop keys and value types — if it hallucinates a key that does not exist in the component definition, the block fails to render silently.&lt;/p&gt;

&lt;p&gt;My solution was to include a condensed block schema in the system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Block schemas (strict — use ONLY these keys)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;HeroSection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;headline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string (max 60 chars, punchy)&lt;/span&gt;
  &lt;span class="na"&gt;subheadline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string (max 120 chars)&lt;/span&gt;
  &lt;span class="na"&gt;ctaLabel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string (max 30 chars)&lt;/span&gt;
  &lt;span class="na"&gt;ctaHref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string (WhatsApp link if phone available, else "#contact")&lt;/span&gt;
  &lt;span class="s"&gt;backgroundImage?&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string (omit — will be set by user)&lt;/span&gt;

&lt;span class="na"&gt;ServicesSection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Array&amp;lt;{ icon&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;star"&lt;/span&gt;&lt;span class="pi"&gt;|&lt;/span&gt;&lt;span class="err"&gt;"check"|"bolt"|"heart"|"shield"|"phone",&lt;/span&gt; &lt;span class="err"&gt;title:&lt;/span&gt; &lt;span class="err"&gt;string,&lt;/span&gt; &lt;span class="err"&gt;description:&lt;/span&gt; &lt;span class="err"&gt;string&lt;/span&gt; &lt;span class="err"&gt;}&amp;gt;&lt;/span&gt;
  &lt;span class="s"&gt;(max 4 items)&lt;/span&gt;

&lt;span class="s"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the schema in the system prompt (not the user message) made it sticky across multi-turn critic passes. I initially put it in the user message, and the critic pass would occasionally "forget" a constraint and hallucinate props.&lt;/p&gt;

&lt;h3&gt;
  
  
  Handling Invalid JSON
&lt;/h3&gt;

&lt;p&gt;Claude is very reliable at producing valid JSON when instructed, but I still wrap every parse in a retry loop with exponential backoff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_with_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;call_claude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="c1"&gt;# Strip markdown code fences if present
&lt;/span&gt;            &lt;span class="n"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^```

json\n?|

```$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MULTILINE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The markdown fence stripping handles the most common failure mode — Claude occasionally wraps JSON in &lt;code&gt;&lt;/code&gt;&lt;code&gt;json&lt;/code&gt;&lt;code&gt;&lt;/code&gt; even when told not to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Image Sourcing
&lt;/h2&gt;

&lt;p&gt;Generated pages reference images by semantic query, not URL. After content generation, a second async step queries the Stock Photos API with auto-derived search terms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hydrate_images&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Replace semantic image placeholders with real Stock Photo API&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s URLs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page_content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backgroundImageQuery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;props&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt;
            &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;props&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backgroundImageQuery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch_stock_photo_apis_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orientation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;landscape&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;props&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backgroundImage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;page_content&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM emits &lt;code&gt;backgroundImageQuery: "nigerian bakery fresh bread"&lt;/code&gt; rather than a URL. The hydration step runs after validation, so a failed image fetch never breaks page storage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Nigerian-Specific Design Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Payment: Paystack, Not Stripe
&lt;/h3&gt;

&lt;p&gt;Paystack is the de facto standard for Nigerian card payments. The integration covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monthly and annual billing plans with plan codes managed in Paystack's dashboard&lt;/li&gt;
&lt;li&gt;Webhook-driven subscription state machine (charge.success → activate, subscription.not_renew → deactivate)&lt;/li&gt;
&lt;li&gt;Naira amounts throughout — no currency conversion complexity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Annual billing required propagating the &lt;code&gt;interval&lt;/code&gt; choice from the marketing pricing page all the way through signup → onboarding → backend → Paystack plan code selection. The key architectural point: the &lt;code&gt;interval&lt;/code&gt; is stored in Paystack webhook metadata so that even if a user's browser crashes between initiating checkout and completing payment, the correct plan is activated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom Domains: Cloudflare for SaaS
&lt;/h3&gt;

&lt;p&gt;Every site gets a free subdomain (&lt;code&gt;yourstore.webdigitize.com&lt;/code&gt;). Paid plans can connect a custom domain. This works via Cloudflare for SaaS: each custom domain is registered as a Cloudflare Custom Hostname, which handles SSL provisioning and CNAME routing without any manual per-site DNS configuration.&lt;/p&gt;

&lt;p&gt;The Next.js middleware reads the incoming hostname, routes to the correct site, and injects the site's theme tokens and content. A single deployment serves thousands of storefronts.&lt;/p&gt;

&lt;h3&gt;
  
  
  WhatsApp as the Default CTA
&lt;/h3&gt;

&lt;p&gt;For most Nigerian SMBs, WhatsApp is the primary business communication channel — not email, not a contact form. The AI generation pipeline is instructed to default CTAs to WhatsApp deep links (&lt;code&gt;https://wa.me/{phone}&lt;/code&gt;) whenever a phone number is available. This alone meaningfully increases the conversion rate of the generated sites for our customers.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Got Wrong (and Fixed)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Trying to generate the shop page with AI&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first iteration asked Claude to generate a shop page layout. The result appeared fine statically, but it was dead, lacking product data, cart integration, and real-time inventory. I wasted a week before accepting that some pages need to be code, not content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Putting too much in a single prompt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first-generation home page prompt tried to generate all six sections in one shot with a 2,000-token instruction block. Output quality was inconsistent. Splitting into a generation pass + a focused critic pass with a shorter prompt produced dramatically better results with less prompt engineering effort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Not fixing &lt;code&gt;props&lt;/code&gt; at the schema layer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Early components had flexible &lt;code&gt;props&lt;/code&gt; — any key was accepted, missing keys fell back to defaults. This made the LLM's job feel easier but pushed validation errors to render time, where they were silent. Switching to strict TypeScript prop types with runtime validation at the schema boundary caught hallucinated keys immediately and gave cleaner error messages during development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Synchronous generation blocking the HTTP response&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first version &lt;code&gt;await&lt;/code&gt;-ed generation inside the request handler. For a 4-page site, that meant a 15–25 second HTTP response. FastAPI's &lt;code&gt;BackgroundTasks&lt;/code&gt; solved this: the response returns immediately with a job ID, the client polls a &lt;code&gt;/status&lt;/code&gt; endpoint, and a Server-Sent Event pushes the "ready" signal. Users see a progress animation instead of a blank spinner.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance and Cost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Average home page generation time: &lt;strong&gt;8–12 seconds&lt;/strong&gt; (two Claude API calls)&lt;/li&gt;
&lt;li&gt;Average full site (4 pages): &lt;strong&gt;20–35 seconds&lt;/strong&gt; (concurrent sub-page generation)&lt;/li&gt;
&lt;li&gt;Claude API cost per site generated: approximately &lt;strong&gt;well within our margin at scale.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Stock Photos API: free tier covers 200 requests/hour, more than sufficient for current volume&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The per-generation cost is negligible relative to monthly subscription revenue.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Editor: Puck
&lt;/h2&gt;

&lt;p&gt;Once a site is generated, users can edit it themselves through the Puck drag-and-drop editor embedded in the dashboard. The same JSON schema the AI produces is the schema the editor operates on — so a human tweak and an AI regeneration produce output in the same format, stored in the same database column.&lt;/p&gt;

&lt;p&gt;This is the key architectural win: &lt;strong&gt;the AI is not a separate system that hands off to the editor&lt;/strong&gt;. It is an initialiser for a data structure that the human can continue editing. Users who want no involvement after generation get a live website immediately. Users who want to customise have a full visual editor.&lt;/p&gt;




&lt;h2&gt;
  
  
  Open Questions and Future Work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Incremental regeneration&lt;/strong&gt;: allow a user to say "regenerate just the services section" without touching the rest of the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice-driven brief&lt;/strong&gt;: record a 60-second audio description, transcribe it with Whisper, and use that as the generation brief. This removes even the typing barrier for less tech-comfortable users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-tuning on accepted/rejected outputs&lt;/strong&gt;: track which generated sites users edit heavily (a signal of poor initial quality) and use that as a training signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multilingual generation&lt;/strong&gt;: Yoruba, Hausa, and Igbo UI and content generation for non-English-primary users.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building this taught me that the hard part of AI product development is rarely the model. It is the interface between the model's output and the rest of your system: schema design, validation, graceful degradation, and the user experience around the inevitable latency.&lt;/p&gt;

&lt;p&gt;The dual-pass generation + critic architecture is the single most impactful thing I did to improve output quality. The schema-in-system-prompt trick is a close second.&lt;/p&gt;

&lt;p&gt;If you are building anything similar, my recommendations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Model output is a first draft. Build a critic step into your pipeline.&lt;/li&gt;
&lt;li&gt;Schema constraints belong in the system prompt, not the user message.&lt;/li&gt;
&lt;li&gt;Separate deterministic UI (shop, cart, checkout) from generated content.&lt;/li&gt;
&lt;li&gt;Async everything — users will not wait 15 seconds for a first response.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WebDigitize is live at &lt;a href="https://webdigitize.com" rel="noopener noreferrer"&gt;webdigitize.com&lt;/a&gt;.&lt;br&gt;
Kindly share your thoughts (and questions) on this architecture. Do you think the design is scalable, and what are some risks you would flag in a critical review?&lt;/p&gt;




</description>
      <category>python</category>
      <category>fastapi</category>
      <category>nextjs</category>
      <category>promptengineering</category>
    </item>
  </channel>
</rss>
