Build a V0 Capture Page
Guide to generating a Lumail-branded capture page with V0.dev and wiring it to the Subscribers API.
This guide walks you through building a high-converting capture page for Lumail in less than an hour using V0.dev. You will design the page with V0's AI UI builder, connect the form to Lumail's Subscribers API, and keep your organization token secure.
Why use V0.dev?
- Rapid iteration – generate production-grade Next.js layouts by describing the outcome you want.
- Consistency – reuse Lumail branding tokens and typography in the prompt to keep the landing page on-brand.
- Developer handoff – V0 produces file-based Next.js code you can drop into this repository or a standalone capture micro-site.
Prerequisites
- A Lumail organization with permission to create API tokens.
- A V0.dev account connected to GitHub (recommended) or ready to download generated code.
- Node.js 18+ and
pnpmif you plan to run the generated project locally. - A fresh API token dedicated to this capture page.
Generate a Lumail API token
- Open your Lumail organization dashboard and navigate to Settings → API Tokens (the URL will be
/orgs/[your-org]/settings/tokens). - Click Generate Token and create a token named
v0-capture-page. - Copy the token immediately—it will only be shown once during creation.
- Store it securely as an environment variable (we'll set this up in Step 3).
Security Warning: Never expose your API token in client-side code. Always keep it server-side using environment variables and API routes.
Tokens are validated server-side through
tokenRouteinapp/api/v1/subscribers/route.ts, which delegates togetCurrentOrgFromBearerto ensure every request is tied to the correct organization and enforces plan limits before creating a subscriber.
Step 1 — Prompt V0 for the layout
-
Create a new V0 project and select Next.js App Router.
-
In the prompt, describe your target audience, tone, and conversion goal. Example prompt:
"Design a minimal capture page for Lumail, an AI-assisted email marketing platform. Hero with headline, subheading, email capture form, three value props with icons, customer logos, and a final CTA. Visual style: clean, trustworthy, gradient accents in Lumail teal (
#12A6D1)." -
Let V0 generate the initial layout. If needed, iterate with follow-up prompts (e.g., "make hero background a subtle gradient," "add testimonials section").
-
Save the iteration you like as
app/(marketing)/capture/page.tsx. V0 will create the file structure for you.
Step 2 — Add the capture form block
-
Ask V0 to insert a form component underneath the hero copy:
"Add a form with an email input, optional first name field, and a submit button labeled
Join the Lumail waitlist." -
Confirm the generated component uses
type="email"validation and has accessible labels. -
Rename the form component to
CaptureFormto keep the file readable. -
Add hidden metadata fields if you need tags or source tracking:
<input type="hidden" name="tags" value='["v0-capture","waitlist"]' /> <input type="hidden" name="fields[source]" value="lumail.io/v0" />
The Subscribers API will merge tags and fields with the defaults defined in SubscriberSchema (app/api/v1/subscribers/_schemas/api-subscriber.schema.ts).
Step 3 — Connect the form to Lumail securely
Never expose your API token in client-side code. Always create a backend API route that stores the token server-side.
3.1 Set up environment variables
Create a .env.local file in your project root:
LUMAIL_API_TOKEN=your_token_here
Add
.env.localto your.gitignoreto prevent committing secrets.
3.2 Create a backend API route
Create app/api/subscribe/route.ts to handle form submissions securely:
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
// Parse the incoming request
const body = await request.json();
// Call Lumail API with your token (server-side only)
const response = await fetch("https://lumail.io/api/v1/subscribers", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.LUMAIL_API_TOKEN}`,
},
body: JSON.stringify({
email: body.email,
name: body.name,
tags: body.tags || [],
fields: body.fields || {},
replaceTags: false,
}),
});
if (!response.ok) {
const error = await response.json();
return NextResponse.json(
{ error: error.message || "Failed to subscribe" },
{ status: response.status },
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: "Unable to subscribe right now." },
{ status: 500 },
);
}
}
What this does:
- Receives form data from your frontend
- Calls Lumail's API with the token stored in
.env.local - Returns the response to the frontend
- Token stays on the server and is never exposed to the browser
3.3 Call the API route from your form
Update your V0-generated form component:
"use client";
import { useState } from "react";
export function CaptureForm() {
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage("");
try {
const response = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
name,
tags: ["v0-capture", "waitlist"],
fields: { source: "lumail.io/v0" },
}),
});
const data = await response.json();
if (!response.ok) {
setMessage(data.error || "Something went wrong");
} else {
setMessage("You're in! Check your inbox.");
setEmail("");
setName("");
}
} catch (error) {
setMessage("Unable to subscribe right now.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="email"
required
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded border px-4 py-2"
/>
<input
type="text"
placeholder="Name (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded border px-4 py-2"
/>
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
>
{loading ? "Subscribing..." : "Join the waitlist"}
</button>
{message && <p className="text-sm">{message}</p>}
</form>
);
}
3.4 Better approach with Zod validation
For production apps, add validation using Zod to match Lumail's API schema:
-
Install Zod:
pnpm add zod -
Update
app/api/subscribe/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
// This schema matches Lumail's backend validation
const SubscriberSchema = z.object({
email: z.string().email(),
name: z.string().optional().nullable(),
phone: z.string().optional().nullable(),
tags: z.array(z.string()).optional().default([]),
fields: z.record(z.string().optional().nullable()).optional(),
replaceTags: z.boolean().optional(),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate the request body
const validated = SubscriberSchema.safeParse(body);
if (!validated.success) {
return NextResponse.json(
{ error: "Invalid form data", details: validated.error.errors },
{ status: 400 },
);
}
const response = await fetch("https://lumail.io/api/v1/subscribers", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.LUMAIL_API_TOKEN}`,
},
body: JSON.stringify(validated.data),
});
if (!response.ok) {
const error = await response.json();
return NextResponse.json(
{ error: error.message || "Failed to subscribe" },
{ status: response.status },
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: "Unable to subscribe right now." },
{ status: 500 },
);
}
}
3.5 Production-ready approach with up-fetch
For even better type safety and automatic validation, use up-fetch:
-
Install:
pnpm add up-fetch -
Create
lib/upfetch.ts:
import { up } from "up-fetch";
export const upfetch = up(fetch);
- Update
app/api/subscribe/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { upfetch } from "@/lib/upfetch";
const SubscriberSchema = z.object({
email: z.string().email(),
name: z.string().optional().nullable(),
tags: z.array(z.string()).optional().default([]),
fields: z.record(z.string().optional().nullable()).optional(),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validated = SubscriberSchema.safeParse(body);
if (!validated.success) {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
// upfetch automatically validates response and infers types
const data = await upfetch("https://lumail.io/api/v1/subscribers", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.LUMAIL_API_TOKEN}`,
},
body: validated.data,
schema: z.object({
success: z.literal(true),
subscriber: z.object({
id: z.string(),
email: z.string().email(),
}),
}),
});
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: "Unable to subscribe right now." },
{ status: 500 },
);
}
}
Benefits of up-fetch:
- Automatic JSON parsing and serialization
- Response validation with Zod schemas
- Full TypeScript type inference
- Better error handling
When the request reaches
POST /api/v1/subscribers, Lumail's handler automatically infers the organization from the token, applies plan limits, and returns the normalized subscriber record.
Step 4 — Tag submissions for downstream workflows
Leverage tags and fields so the new subscribers drop into the right automations:
tags: ["v0-capture", "marketing-site"]to segment the list.fields[source] = window.location.pathnamecaptured server-side to track the exact page.fields[utm_campaign]if you pipe UTM parameters into hidden inputs.
Because the backend replaces tags only if replaceTags: true, you can safely append campaign-specific tags without wiping existing labels.
Step 5 — Deploy and test
Local testing
- Make sure your
.env.localfile contains yourLUMAIL_API_TOKEN. - Run the dev server:
pnpm devornpm run dev. - Test the form by submitting a real email address.
- Check your Lumail dashboard at
/orgs/[your-org]/subscribersto confirm the subscriber was added. - Trigger validation errors (empty email, invalid format) to ensure error handling works.
Deploy to Vercel
- Push your V0 project to GitHub.
- Import the repository into Vercel.
- Add environment variable: In Vercel project settings, go to Settings → Environment Variables.
- Add
LUMAIL_API_TOKENwith your token value for Production, Preview, and Development environments. - Deploy the project.
- Test the live form to ensure it's working in production.
Tip: When you rotate your API token in Lumail, remember to update it in Vercel's environment variables and redeploy.
Operational tips
- Rotate the token periodically. Lumail tokens are stored hashed in Prisma (
apiTokentable) and tied to plan limits, so revoking a leaked token immediately blocks unauthorized capture pages. - Instrument analytics (PostHog or Vercel Web Analytics) to measure form conversion. V0 generates client components that accept additional tracking hooks.
- Consider adding a double opt-in flow by redirecting to a
/thank-youpage that explains what to expect next.
Recap
By combining V0.dev's AI-generated UI with Lumail's secure subscriber endpoint, you can launch bespoke capture experiences quickly while keeping organization tokens guarded on the server.
Implementation approaches summary
-
Basic (Step 3.2-3.3): Plain fetch API with Next.js API routes
- Simple and straightforward
- No extra dependencies
- Good for prototypes and simple projects
- Manual validation and error handling
-
Better (Step 3.4): Add Zod validation
- Schema validation matches Lumail's API
- Catches errors before making API calls
- Better error messages for users
- Requires:
zod
-
Production-ready (Step 3.5): Zod + up-fetch
- Full type safety with TypeScript inference
- Automatic JSON handling
- Response validation
- Cleaner error handling
- Requires:
zod,up-fetch
Choose the approach that fits your project:
- Building a quick MVP? Start with the basic approach (3.2-3.3)
- Launching to real users? Use Zod validation (3.4)
- Production app with TypeScript? Use up-fetch (3.5)
Reuse this pattern for campaign-specific landing pages, webinar waitlists, or partner co-marketing forms without rewriting the integration.
Related Documentation
- API Tokens Reference - Complete token management guide
- Create API Token Tutorial - Step-by-step token creation
- Create Subscriber API - API endpoint reference
- Tags API - Manage subscriber tags
- Workflows - Trigger workflows from form submissions
- Web Domains - Host capture pages on custom domains