Guides

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

v0 builds Next.js apps and deploys them to Vercel, so the cleanest contact form is a Server Action with your key in an environment variable. Here’s the prompt, the code, and a client-side option.

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, which has more background on both options.

1Create a FormSMTP project

Sign up free 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, Microsoft 365, Zoho, 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.

2Add 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.

3Paste 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.

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

5Option 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

Get a Founding Pass

10,000 emails a month, free for your first year. Pass No. 0008 is next: 1,993 of 2,000 left, no card.