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
);
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} />;
Two things to notice:
-
Common props always go through
commonProps. When I added multi-currency support last week, I addedcurrencyto that object once. Every LP got it. -
book_callhas 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
});
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 fillsemail_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());
}
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]);
Two lessons here that I'd put in a "things I should have done day 1" file:
-
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. - 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)
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.
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
anyeverywhere. 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_percentfield that's only valid whentype 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?Happy to dig into any specific decision, the polymorphic-LP call is the one I'm least sure about