# v0 contact form that sends email (Next.js on Vercel)

> Add email to a v0 by Vercel app: a prompt for v0, a Next.js Server Action that keeps your API key in an environment variable, and a client-side option.

Source: https://formsmtp.com/guides/v0-contact-form · Last updated November 3, 2026

## What v0 gives you

New v0 chats start with a Next.js app that uses shadcn/ui and Tailwind CSS, and v0 deploys projects to Vercel. That means your app has a server, so the API key can stay out of the browser entirely. Three details matter for email:

- **Environment variables** live under *Project menu → Settings → Environment Variables*, stored encrypted on Vercel.
- **The v0 preview** only sees variables enabled for the *Development* environment. Production and Preview are separate.
- **Preview deployments** of working branches get their own unique URLs.

This guide is the v0 version of the [Next.js contact form guide](https://formsmtp.com/guides/nextjs-contact-form), which has more background on both options.

## 1. Create a FormSMTP project

[Sign up free](https://app.formsmtp.com/#/signup) and add a project. Pick your email provider so the SMTP host and port fill in, enter your login, and click *Test connection*. Any mailbox with SMTP access works: [Gmail or Google Workspace](https://formsmtp.com/guides/gmail-smtp-contact-form), [Microsoft 365](https://formsmtp.com/guides/outlook-smtp-contact-form), [Zoho](https://formsmtp.com/guides/zoho-smtp-contact-form), your web host’s mailbox, or a sending service. Personal Outlook.com and Hotmail addresses can’t be used, because Microsoft only lets them sign in with OAuth.

Under *Settings → Security*, set **Allowed recipients** to your own address and set **Max API emails per hour** to something modest, like 30. Copy the **API key** (it starts with `fs_`) from *API & code*.

## 2. Add the key to v0

Open *Project menu → Settings → Environment Variables* and add `FORMSMTP_API_KEY` with your API key. Enable it for Development (so the preview works), Preview and Production. Don’t prefix it with `NEXT_PUBLIC_`: that would build it into the browser code.

## 3. Paste this prompt into v0

```text
Add a contact page with a form that emails me through FormSMTP.

- Use a Server Action. Read the API key from process.env.FORMSMTP_API_KEY
  on the server only; never expose it to the client.
- The action POSTs to https://app.formsmtp.com/api/v1/send with the header
  Authorization: Bearer <FORMSMTP_API_KEY> and a JSON body:
  { to: "you@yourdomain.com", replyTo: <visitor email>,
    subject: "New message from <visitor name>", text: <message> }.
- The response is { success: true, messageId } or { success: false, error }.
  Return { ok: true } or { ok: false, error } to the form.
- Add a hidden honeypot field named "company"; if it's filled in, return
  { ok: true } without sending.
- Build the form with shadcn/ui (Label, Input, Textarea, Button) and
  useActionState for the pending, success and error states.
```

Replace `you@yourdomain.com` with the address you put in Allowed recipients.

## 4. The Server Action and form

This is what the prompt should produce. Paste it yourself if you prefer.

```ts
// app/contact/actions.ts
"use server";

export type ContactState = { ok: boolean; error?: string } | null;

export async function sendContact(_: ContactState, formData: FormData): Promise<ContactState> {
  if (formData.get("company")) return { ok: true }; // honeypot: bots fill it in

  const name = String(formData.get("name") || "").trim();
  const email = String(formData.get("email") || "").trim();
  const message = String(formData.get("message") || "").trim();
  if (!name || !email || !message) return { ok: false, error: "Please fill in every field." };

  const res = await fetch("https://app.formsmtp.com/api/v1/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FORMSMTP_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to: "you@yourdomain.com",
      replyTo: email,
      subject: `New message from ${name}`,
      text: message,
    }),
  });
  const data = await res.json();
  if (!data.success) {
    console.error("FormSMTP:", data.error); // the details stay in your server logs
    return { ok: false, error: "Sorry, your message couldn't be sent. Please try again later." };
  }
  return { ok: true };
}
```

```tsx
// app/contact/page.tsx
"use client";

import { useActionState } from "react";
import { sendContact } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";

export default function ContactPage() {
  const [state, action, pending] = useActionState(sendContact, null);

  if (state?.ok) return <p className="p-6">Thanks! We'll get back to you soon.</p>;

  return (
    <form action={action} className="mx-auto grid max-w-md gap-4 p-6">
      <div className="grid gap-2">
        <Label htmlFor="name">Name</Label>
        <Input id="name" name="name" autoComplete="name" required />
      </div>
      <div className="grid gap-2">
        <Label htmlFor="email">Email</Label>
        <Input id="email" name="email" type="email" autoComplete="email" required />
      </div>
      <div className="grid gap-2">
        <Label htmlFor="message">Message</Label>
        <Textarea id="message" name="message" rows={5} required />
      </div>
      <input name="company" className="hidden" tabIndex={-1} autoComplete="off" />
      <Button type="submit" disabled={pending}>{pending ? "Sending…" : "Send message"}</Button>
      {state?.error && <p role="alert" className="text-sm text-destructive">{state.error}</p>}
    </form>
  );
}
```

Requests from your server carry no browser Origin, so *Allowed websites* doesn’t apply here; the key stays private instead. FormSMTP’s per-visitor limits are for the public form endpoint, so on this route the honeypot and your hourly cap are what stop abuse.

## 5. Option B: A client-side form

Exporting a static site, or want FormSMTP’s per-visitor limits and spam filter? Post from the browser with the public form key instead. Add `NEXT_PUBLIC_FORMSMTP_FORM_KEY` with your `fk_` key, and set *Allowed websites* to your production `vercel.app` address and custom domain. Preview deployments have their own URLs, so test on production or add a preview URL while you test.

```tsx
"use client";
import { useState } from "react";

export default function ContactForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");
    const res = await fetch("https://app.formsmtp.com/api/v1/form/" + process.env.NEXT_PUBLIC_FORMSMTP_FORM_KEY, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(Object.fromEntries(new FormData(e.currentTarget))),
    });
    setStatus((await res.json()).success ? "sent" : "error");
  }

  if (status === "sent") return <p>Thanks! Message sent.</p>;
  return (
    <form onSubmit={onSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <input name="_gotcha" className="hidden" tabIndex={-1} autoComplete="off" />
      <button disabled={status === "sending"}>Send</button>
      {status === "error" && <p role="alert">Something went wrong. Please try again.</p>}
    </form>
  );
}
```

## Test it, then publish

v0 previews run your full app, server code included, so you can test the Server Action before publishing. Send yourself a message from the preview: it should arrive from your own address, with the visitor’s email as reply-to, and appear under *Logs* in FormSMTP. If it fails, the form shows FormSMTP’s error message, which names the problem.

Then publish. Publishing updates the Vercel project’s production deployment, which reads the Production copy of `FORMSMTP_API_KEY`, so send one more test from the live URL.

## Troubleshooting

- **“Missing API key” in the v0 preview.** The variable isn’t enabled for the Development environment, which is the only one the preview can read.
- **Works in the preview, fails in production.** The variable isn’t enabled for Production. Add it, then publish again.
- **“That is a form key”.** The Server Action is using the `fk_` key. The send API needs the `fs_` API key.
- **“Recipient not allowed for this project”.** The `to` address doesn’t match *Allowed recipients*.
- **Client-side form: “This form isn’t allowed on …”.** Add the exact address from the error, usually a preview deployment URL.

## FAQ

### Should I use the Server Action or the client-side form?

The Server Action, whenever the app runs on Vercel, which is v0’s default. The key never reaches the browser. Use the client-side form for static exports.

### Do I need a Vercel Marketplace integration for email?

No. FormSMTP is a single HTTPS call with one environment variable, and it sends through the SMTP account you already have.

### Can the form email the visitor too?

Yes, on the Server Action route: send a second email to the visitor’s address. Set Allowed recipients to allow it (or leave it empty) and keep the hourly cap low, since anyone can submit the form.

### Why did my message land in spam?

Usually the From address and the SMTP account don’t match, or the domain has no SPF/DKIM records. Send from the address you log in with, and check the Deliverability panel in FormSMTP’s Settings.

## More guides

- [Webflow form to email: custom action with your SMTP](https://formsmtp.com/guides/webflow-contact-form)
- [Framer form to email: webhook or code component](https://formsmtp.com/guides/framer-contact-form)
- [Carrd contact form that emails you from your address](https://formsmtp.com/guides/carrd-contact-form)
- [Lovable contact form that sends email via your SMTP](https://formsmtp.com/guides/lovable-contact-form)
