This guide walks you through building a high-converting capture page for Lumail in less than an hour using [V0.dev](https://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 `pnpm` if you plan to run the generated project locally.
- A fresh API token dedicated to this capture page.

### Generate a Lumail API token

1. Open your Lumail organization dashboard and navigate to **Settings → API Tokens** (the URL will be `/orgs/[your-org]/settings/tokens`).
2. Click **Generate Token** and create a token named `v0-capture-page`.
3. Copy the token **immediately**—it will only be shown once during creation.
4. 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 `tokenRoute` in `app/api/v1/subscribers/route.ts`, which delegates to `getCurrentOrgFromBearer` to ensure every request is tied to the correct organization and enforces plan limits before creating a subscriber.

## Step 1 — Prompt V0 for the layout

1. Create a new V0 project and select **Next.js App Router**.
2. 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`)."

3. Let V0 generate the initial layout. If needed, iterate with follow-up prompts (e.g., "make hero background a subtle gradient," "add testimonials section").

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

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

2. Confirm the generated component uses `type="email"` validation and has accessible labels.
3. Rename the form component to `CaptureForm` to keep the file readable.
4. Add hidden metadata fields if you need tags or source tracking:

   ```tsx
   <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:

```bash
LUMAIL_API_TOKEN=your_token_here
```

> Add `.env.local` to your `.gitignore` to prevent committing secrets.

### 3.2 Create a backend API route

Create `app/api/subscribe/route.ts` to handle form submissions securely:

```tsx
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:

```tsx
"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:

1. Install Zod: `pnpm add zod`

2. Update `app/api/subscribe/route.ts`:

```tsx
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`:

1. Install: `pnpm add up-fetch`

2. Create `lib/upfetch.ts`:

```tsx
import { up } from "up-fetch";

export const upfetch = up(fetch);
```

3. Update `app/api/subscribe/route.ts`:

```tsx
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.pathname` captured 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

1. Make sure your `.env.local` file contains your `LUMAIL_API_TOKEN`.
2. Run the dev server: `pnpm dev` or `npm run dev`.
3. Test the form by submitting a real email address.
4. Check your Lumail dashboard at `/orgs/[your-org]/subscribers` to confirm the subscriber was added.
5. Trigger validation errors (empty email, invalid format) to ensure error handling works.

### Deploy to Vercel

1. Push your V0 project to GitHub.
2. Import the repository into Vercel.
3. **Add environment variable**: In Vercel project settings, go to **Settings → Environment Variables**.
4. Add `LUMAIL_API_TOKEN` with your token value for **Production**, **Preview**, and **Development** environments.
5. Deploy the project.
6. 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 (`apiToken` table) 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-you` page 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

1. **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

2. **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`

3. **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](/docs/api-reference/api-tokens) - Complete token management guide
- [Create API Token Tutorial](/docs/tutorials/create-api-token) - Step-by-step token creation
- [Create Subscriber API](/docs/api-reference/api-subscribers-post) - API endpoint reference
- [Tags API](/docs/api-reference/api-tags-get) - Manage subscriber tags
- [Workflows](/docs/workflows) - Trigger workflows from form submissions
- [Web Domains](/docs/domains/web-domains) - Host capture pages on custom domains
