DEV Community

Cover image for How I Built a Multi-Page AI Website Generator for Nigerian SMBs — Architecture, LLM Prompting, and Lessons Learned
Innocent Oyebode
Innocent Oyebode

Posted on • Originally published at webdigitize.com

How I Built a Multi-Page AI Website Generator for Nigerian SMBs — Architecture, LLM Prompting, and Lessons Learned

The Problem

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.

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.

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.

The hard part is step one: taking those five fields and producing a website that looks like a human designed it.


The Architecture at a Glance

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)
Enter fullscreen mode Exit fullscreen mode

The core website builder is Puck — an open-source React drag-and-drop editor. Puck stores page content as a JSON document describing a tree of typed blocks (HeroSection, FeaturesGrid, TestimonialCard, etc.). The AI's job is to generate that JSON document, filled with content that makes sense for the specific business.


The Generation Pipeline

Step 1: Business Brief

The onboarding wizard collects:

interface BusinessBrief {
  businessName: string;
  businessType: string;       // "Restaurant", "Law firm", "Tech startup", etc.
  description: string;        // free-text, up to ~300 chars
  style: "modern" | "classic" | "bold" | "minimal";
  city: string;
  phone: string;
  whatsapp?: string;
  instagram?: string;
  services?: string[];        // optional structured list
}
Enter fullscreen mode Exit fullscreen mode

The style 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.

Step 2: Home Page Generation (Always Async)

Generation happens in a FastAPI background task so the HTTP response returns immediately and the user sees a progress screen:

@router.post("/onboarding")
async def complete_onboarding(
    body: OnboardingRequest,
    background_tasks: BackgroundTasks,
    ...
):
    site = create_site(db, user, body)
    background_tasks.add_task(generate_site_content, site.id, body)
    return {"status": "generating", "site_id": site.id}
Enter fullscreen mode Exit fullscreen mode

The generation function calls Claude with a carefully structured system prompt and returns a Puck JSON document:

async def generate_home_page(brief: BusinessBrief) -> dict:
    system = """
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 "Lorem ipsum".
All phone numbers must use the Nigerian format provided.
Business hours should reflect typical Nigerian business culture (Mon-Sat, 8am-6pm).
    """

    user_message = f"""
Generate a home page for:
Business name: {brief.business_name}
Type: {brief.business_type}
Description: {brief.description}
City: {brief.city}
Phone: {brief.phone}
Style preference: {brief.style}

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.
    """

    response = await anthropic_client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=4096,
        system=system,
        messages=[{"role": "user", "content": user_message}],
    )
    return json.loads(response.content[0].text)
Enter fullscreen mode Exit fullscreen mode

Step 3: The Design Critic Pass

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:

async def critic_review(brief: BusinessBrief, draft: dict) -> dict:
    critique_prompt = f"""
Review this website draft for {brief.business_name} ({brief.business_type}).

Check for:
1. Headlines that are too generic ("Welcome to our website") — rewrite them to be specific
2. Services that don'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., "Schedule a meeting" for a street-food business) — localise
5. Missing local context (area of {brief.city}, local terminology)

Return the improved Puck JSON. If the draft is already strong, return it unchanged.
    """

    response = await anthropic_client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=4096,
        messages=[
            {"role": "user", "content": critique_prompt},
            {"role": "assistant", "content": json.dumps(draft)},
            {"role": "user", "content": "Improve the draft based on the criteria above. Return only JSON."},
        ],
    )
    return json.loads(response.content[0].text)
Enter fullscreen mode Exit fullscreen mode

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

Step 4: Sub-Page Generation (Paid Plans)

Growth and Pro plan customers get full multi-page sites. After the home page is stored, additional pages are generated in parallel:

async def generate_subpages(site_id: int, brief: BusinessBrief):
    pages_to_generate = ["about", "services", "contact"]
    tasks = [generate_page(brief, page) for page in pages_to_generate]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    for page, result in zip(pages_to_generate, results):
        if not isinstance(result, Exception):
            store_page(site_id, page, result)
Enter fullscreen mode Exit fullscreen mode

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

Step 5: The Shop Page (Deterministic, No AI)

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.

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


The Puck JSON Schema Problem

The hardest part of this project was not prompting — it was schema design.

Puck's content format looks like this:

{
  "root": { "props": {} },
  "content": [
    {
      "type": "HeroSection",
      "props": {
        "headline": "Fresh Bread, Delivered Daily",
        "subheadline": "Abuja's favourite artisan bakery since 2018",
        "ctaLabel": "Order via WhatsApp",
        "ctaHref": "https://wa.me/2348012345678"
      }
    }
  ],
  "zones": {}
}
Enter fullscreen mode Exit fullscreen mode

Each block type has its own props 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.

My solution was to include a condensed block schema in the system prompt:

Block schemas (strict — use ONLY these keys):

HeroSection:
  headline: string (max 60 chars, punchy)
  subheadline: string (max 120 chars)
  ctaLabel: string (max 30 chars)
  ctaHref: string (WhatsApp link if phone available, else "#contact")
  backgroundImage?: string (omit — will be set by user)

ServicesSection:
  title: string
  services: Array<{ icon: "star"|"check"|"bolt"|"heart"|"shield"|"phone", title: string, description: string }>
  (max 4 items)

...
Enter fullscreen mode Exit fullscreen mode

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.

Handling Invalid JSON

Claude is very reliable at producing valid JSON when instructed, but I still wrap every parse in a retry loop with exponential backoff:

async def generate_with_retry(brief, page_type, max_attempts=3):
    for attempt in range(max_attempts):
        try:
            raw = await call_claude(brief, page_type)
            # Strip markdown code fences if present
            cleaned = re.sub(r'^```

json\n?|

```$', '', raw.strip(), flags=re.MULTILINE)
            return json.loads(cleaned)
        except json.JSONDecodeError:
            if attempt == max_attempts - 1:
                raise
            await asyncio.sleep(2 ** attempt)
Enter fullscreen mode Exit fullscreen mode

The markdown fence stripping handles the most common failure mode — Claude occasionally wraps JSON in json even when told not to.


Image Sourcing

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:

async def hydrate_images(site: Site, page_content: dict) -> dict:
    """Replace semantic image placeholders with real Stock Photo API's URLs."""
    for block in page_content.get("content", []):
        if "backgroundImageQuery" in block.get("props", {}):
            query = block["props"].pop("backgroundImageQuery")
            url = await fetch_stock_photo_apis_image(query, orientation="landscape")
            if url:
                block["props"]["backgroundImage"] = url
    return page_content
Enter fullscreen mode Exit fullscreen mode

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


Nigerian-Specific Design Decisions

Payment: Paystack, Not Stripe

Paystack is the de facto standard for Nigerian card payments. The integration covers:

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

Annual billing required propagating the interval choice from the marketing pricing page all the way through signup → onboarding → backend → Paystack plan code selection. The key architectural point: the interval 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.

Custom Domains: Cloudflare for SaaS

Every site gets a free subdomain (yourstore.webdigitize.com). 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 per-site DNS configuration on our side.

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.

WhatsApp as the Default CTA

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 (https://wa.me/{phone}) whenever a phone number is available. This alone meaningfully increases the conversion rate of the generated sites for our customers.


What I Got Wrong (and Fixed)

1. Trying to generate the shop page with AI

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.

2. Putting too much in a single prompt

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.

3. Not fixing props at the schema layer

Early components had flexible props — 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.

4. Synchronous generation blocking the HTTP response

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


Performance and Cost

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

The per-generation cost is negligible relative to monthly subscription revenue.


The Editor: Puck

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.

This is the key architectural win: the AI is not a separate system that hands off to the editor. 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.


Open Questions and Future Work

  • Incremental regeneration: allow a user to say "regenerate just the services section" without touching the rest of the page.
  • Voice-driven brief: 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.
  • Fine-tuning on accepted/rejected outputs: track which generated sites users edit heavily (a signal of poor initial quality) and use that as a training signal.
  • Multilingual generation: Yoruba, Hausa, and Igbo UI and content generation for non-English-primary users.

Conclusion

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.

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.

If you are building anything similar, my recommendations:

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

WebDigitize is live at webdigitize.com. If you are a Nigerian developer interested in contributing or a business owner who wants to try it, reach out.

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?


Top comments (0)