DEV Community

Cover image for How I built a 6-CTA landing page generator with Next.js 16 + AI
Youssefroop
Youssefroop

Posted on

How I built a 6-CTA landing page generator with Next.js 16 + AI

TL;DR — I built a landing page generator with 6 distinct conversion modes that share one form, one AI prompt, and one backend. The trick wasn't the AI part. The trick was modeling "conversion intent" as a first-class primitive so a single launch could become a Stripe checkout, a COD form, a calendar booking, or a waitlist — without forking the codebase six ways.

Stack: Next.js 16 App Router, Supabase, Stripe + PayPal, OpenAI, Tailwind, deployed on Vercel.


The problem with "landing page builders"

Every landing page builder I've used (Carrd, Framer, Webflow, Unbounce) treats the page like the product. You drag a button onto a canvas, you link it somewhere, you ship.

That works if you're selling one thing in one way. The moment you have:

  • a SaaS with a "Start Free" trial AND a "Book a Demo" path,
  • a dropshipping store doing Cash on Delivery in some countries and Stripe in others,
  • a salon taking online bookings,
  • a consultant who wants a "Request Quote" form,

…you end up bolting together Calendly + Typeform + Stripe Payment Links + an email tool, glued with Zapier. Every page has a different lookalike, a different analytics surface, and a different broken integration on launch day.

I wanted a single primitive: pick what you're selling and how someone converts. Generate the page. Ship.

So I built PageStrike, and the architectural decision that made it work was treating conversion mode as a discriminated union, not a UI choice.


The 6-CTA mental model

Every landing page on the internet collapses into one of six conversion shapes:

action_mode What happens on click Real-world use
buy_now Stripe / PayPal checkout E-commerce, digital goods
start_free Redirect to external signup SaaS free trials
collect_emails Form → email list Waitlists, beta signups, lead magnets
cash_on_delivery Form → order with no upfront payment MENA / LATAM e-commerce
request_quote Form → quote inquiry pipeline Agencies, consulting, B2B
book_call Calendar slot → confirmed booking Salons, consulting, legal, transport

That's the whole product surface. A user picks one. Everything downstream — what fields to ask for, what the public page looks like, what happens on submit, how the dashboard shows results — flows from that single column on the launches table:

CREATE TABLE launches (
  id uuid PRIMARY KEY,
  workspace_id uuid REFERENCES workspaces(id),
  action_mode text NOT NULL CHECK (
    action_mode IN ('buy_now','start_free','collect_emails',
                    'cash_on_delivery','request_quote','book_call')
  ),
  -- … 30+ other columns the AI fills in
);
Enter fullscreen mode Exit fullscreen mode

This single discriminator is what lets the rest of the system stay sane.


Polymorphic page rendering

Every public landing page lives at /p/[slug]. The server component reads the launch, then dispatches to a category-specific React component:

// src/app/p/[slug]/page.tsx (simplified)
const mode = launch?.action_mode || "start_free";

const commonProps = {
  sections: sections ?? [],
  pageId: page.id,
  launchId: page.launch_id,
  workspaceId: page.workspace_id,
  companyName: workspace?.company_name,
  currency: launch?.currency ?? null,
};

if (mode === "collect_emails") {
  return <EmailCaptureLP {...commonProps}
    emailCaptureType={launch?.email_capture_type}
    leadMagnetUrl={launch?.lead_magnet_url} />;
}

if (mode === "request_quote") {
  return <QuoteRequestLP {...commonProps} {...quoteProps} />;
}

if (mode === "book_call") {
  // Sub-routing: salon vs legal vs consulting vs generic
  switch (bookingHost?.booking_category) {
    case "legal":      return <LegalLP {...commonProps} {...legalProps} />;
    case "consulting": return <ConsultingLP {...commonProps} {...consultingProps} />;
    case "salon":      return <SalonLP {...commonProps} {...salonProps} />;
    default:           return <BookCallLP {...commonProps} {...bookCallProps} />;
  }
}

// buy_now, start_free, cash_on_delivery use unified <SectionedLP />
return <SectionedLP {...commonProps} actionMode={mode} />;
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  1. Common props always go through commonProps. When I added multi-currency support last week, I added currency to that object once. Every LP got it.
  2. book_call has a second-level discriminator (booking_category) because a salon booking and a legal consultation share zero UI assumptions — staff picker vs. credentials list, gallery vs. office photo, allowed-days array vs. weekday range.

The category-specific LPs are not shared components with conditional rendering. They're separate files. Trying to unify them with a giant if (category === "salon") prop forest is the trap. They share atoms (button, input, calendar), not molecules.


The AI generation pipeline

The "AI" in "AI landing page generator" is the least interesting part technically. It's a structured-output prompt that returns JSON matching the launches schema.

// src/app/api/ai/generate-launch/route.ts (simplified)
const response = await openai.chat.completions.create({
  model: "gpt-4o-2024-08-06",
  response_format: {
    type: "json_schema",
    json_schema: { name: "launch", schema: LAUNCH_SCHEMA, strict: true },
  },
  messages: [
    { role: "system", content: SYSTEM_PROMPT_FOR_MODE[actionMode] },
    { role: "user",   content: userBrief },
  ],
});

const launch = JSON.parse(response.choices[0].message.content);
await supabase.from("launches").insert({
  workspace_id, action_mode: actionMode, ...launch
});
Enter fullscreen mode Exit fullscreen mode

The interesting part is that each action_mode has its own system prompt because the questions you ask a user are different:

  • For collect_emails, the AI fills email_capture_type, lead_magnet_url, copy that emphasizes scarcity.
  • For cash_on_delivery, it fills product fields, address-collection copy, COD trust signals.
  • For book_call, it generates a host bio, service descriptions, and duration recommendations.

One prompt → one schema → one row. The downstream is deterministic.

Side note for anyone trying this with Kontext Pro for hero image generation: passive prompts like "keep the subject identical, just change the background" will return the source unchanged. You need aggressive transform directives. I lost a day to that.


The multi-currency problem (the part that bit me last week)

A salon in Rabat wants to charge MAD. A consultant in London charges GBP. A SaaS in Berlin charges EUR. PageStrike supports paid bookings — meaning the LP needs a Stripe Checkout or a PayPal order in whatever currency the seller picked.

But Stripe and PayPal don't support the same set of currencies. Stripe takes ~135, PayPal takes 25. And my app's picker offers 40. If a seller picks a currency that PayPal doesn't accept, the PayPal button breaks at runtime.

The fix is a tiny helper that computes the intersection at build time:

// src/lib/currency/payment-providers.ts
import { CURRENCIES } from "@/lib/currency/constants";
import { PAYPAL_SUPPORTED_CURRENCIES } from "@/lib/paypal/currencies";

// Codes Stripe does NOT support among the app's 40. Empty today.
// Kill-switch in case Stripe ever drops one.
const STRIPE_BLOCKED = new Set<string>([]);

/**
 * Returns the set of ISO-4217 codes safe to charge in.
 * Intersection: app menu ∩ PayPal ∩ (Stripe \ blocked).
 */
export function getPayableCurrencies(): Set<string> {
  const out = new Set<string>();
  for (const c of CURRENCIES) {
    if (PAYPAL_SUPPORTED_CURRENCIES.has(c.code) && !STRIPE_BLOCKED.has(c.code)) {
      out.add(c.code);
    }
  }
  return out;
}

export function isPayableCurrency(code: string | null | undefined): boolean {
  if (!code) return false;
  return getPayableCurrencies().has(code.toUpperCase());
}
Enter fullscreen mode Exit fullscreen mode

Then in the launch wizard, this snaps the currency to USD if the seller adds a paid service while sitting on a non-payable code:

const payableCurrencies = useMemo(() => getPayableCurrencies(), []);

useEffect(() => {
  const hasAnyPaid = services.some(
    s => typeof s.price_cents === "number" && s.price_cents > 0
  );
  if (!hasAnyPaid) return;
  if (!payableCurrencies.has(currency)) setCurrency("USD" as CurrencyCode);
}, [services, currency, payableCurrencies]);
Enter fullscreen mode Exit fullscreen mode

Two lessons here that I'd put in a "things I should have done day 1" file:

  1. Never trust a third-party SDK's "supported currencies" list to match yours. Compute the intersection in code, with the SDK's own list imported as a Set. The day Stripe drops a currency, you change one constant.
  2. Validate at the form level, not at the API. By the time the user has clicked Save, picked an unsupported currency, and seen a generic 400, you've burned trust.

Stack summary

Layer Choice Why
Framework Next.js 16 App Router Server Components + async params + route handlers in one tree
DB Supabase (Postgres + RLS) Free auth, row-level security, JSONB for flexible launch shapes
Payments Stripe + PayPal Different geos need different providers — both, not one
AI OpenAI gpt-4o (structured outputs) The json_schema mode is what makes one-shot generation reliable
Hosting Vercel Edge cache for public LPs, Fluid Compute for the API
Bundler Turbopack Next 16 default; HMR is night-and-day vs. Webpack on this size

What I'd do differently if I started over

One. Model action_mode as a discriminated union in TypeScript from day one, not a text column with magic strings. I retrofitted this. It hurt.

Two. Treat each conversion mode's lifecycle as its own table. I started with one engagements table and a kind column. It became a polymorphic mess (an order has tracking_number, a quote has quote_amount, a subscription has email_capture_type). Splitting it into orders, quotes, subscriptions, bookings with category-appropriate status enums is the cleanup I'm doing right now.

Three. Write the dashboard before the public LPs. The dashboard is where you spend hours; the LP is where the visitor spends seconds. I wrote them in the wrong order and the dashboard now has technical debt the LPs don't.


Try it

PageStrike is live at pagestrike.com — free plan, AI-generated landing pages with all 6 CTAs, Stripe + PayPal payments, calendar bookings, multi-currency. Built for indie hackers and small teams who don't want to glue 5 tools together.

If you've built something similar and made different architectural calls, I'd genuinely love to hear them in the comments — particularly around the polymorphic-LP vs. unified-renderer trade-off. I went one way; the other has merits.


This is the first post in what I'm planning as a build-in-public series. Next up: the booking calendar timezone problem and why I almost gave up on it.

Top comments (3)

Collapse
 
lisagela profile image
Lisa Gela

The action_mode as a discriminated union instead of magic strings is one of those things you only appreciate after you spend a weekend refactoring. I've been there with a "type" column that grew 15 values over two years. By the end, no one knew which combinations were valid. Modeling intent upfront saves months of pain. Wish I'd read this two years ago.

Collapse
 
youssefroop profile image
Youssefroop

This is exactly why I wrote the post, to externalize the pain so others don't repeat it.

I almost went the "type" column route too. The thing that saved me was that I started with TypeScript discriminated unions on the client side first, and once I had:

type Launch =
| { action_mode: 'buy_now'; stripe_price_id: string }
| { action_mode: 'book_call'; booking_category: 'salon' | 'legal' | ... }
| ...

I realized the DB had to mirror that shape or I'd be casting any everywhere. The CHECK constraint in Postgres + the TS union became one source of truth.

What's wild is that the "combinations" problem you describe gets exponentially worse with shared columns. A discount_percent field that's only valid when type IN ('promo', 'coupon') is the kind of thing that ends up as a comment in a Slack thread instead of a constraint. Curious, did you eventually refactor, or just live with it?

Collapse
 
youssefroop profile image
Youssefroop

Happy to dig into any specific decision, the polymorphic-LP call is the one I'm least sure about