Framework guides
Add a contact form to a Next.js site
You don't need an API route, a Server Action or an email provider to accept form submissions in Next.js. Post the form to embedio and read submissions in a dashboard. This guide shows two versions: plain HTML that works with JavaScript off, and a client component that submits in place.
1. Create the form in embedio
Sign up (the Free plan includes 500 submissions a month and needs no card), create a form, and copy its key. It looks like frm_ followed by ten characters. Everywhere below, replace frm_your_form_key with yours.
The form key is meant to be public: it only lets people submit to that one form, never read anything. It's safe in client-side code and in your repository.
2. The zero-JavaScript version
A plain <form> with an action works in a Server Component as is. Nothing ships to the browser, and the browser handles the submit. After it, embedio sends people to the _redirect page on your site.
export default function ContactPage() {
return (
<main>
<h1>Contact</h1>
<form action="https://api.tryembedio.com/f/frm_your_form_key" method="POST">
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{/* Honeypot: hidden from people, filled in by bots */}
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
{/* Where to send people after a successful submit (same site) */}
<input type="hidden" name="_redirect" value="/contact/thanks" />
<button type="submit">Send</button>
</form>
</main>
);
}Then add app/contact/thanks/page.tsx with whatever confirmation you like. _redirect only accepts pages on the same site as the form; to send people elsewhere, set a redirect on the form in the dashboard instead.
3. Submitting in place with fetch
If you'd rather keep people on the page, use a client component. Sending JSON (or an Accept: application/json header) makes embedio answer with JSON instead of a page: { ok, message, redirectUrl } on success.
"use client";
import { useState, type FormEvent } from "react";
const ENDPOINT = "https://api.tryembedio.com/f/frm_your_form_key";
type Status =
| { state: "idle" | "sending" | "sent" }
| { state: "error"; message: string };
export function ContactForm() {
const [status, setStatus] = useState<Status>({ state: "idle" });
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setStatus({ state: "sending" });
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
if (response.ok) {
form.reset();
setStatus({ state: "sent" });
return;
}
if (response.status === 429) {
setStatus({ state: "error", message: "Too many attempts. Try again in a minute." });
return;
}
const body = await response.json().catch(() => ({}));
const message =
body.error === "validation_failed"
? body.fields.map((f: { message: string }) => f.message).join(" ")
: "Something went wrong. Please try again.";
setStatus({ state: "error", message });
} catch {
setStatus({ state: "error", message: "Network error. Check your connection and retry." });
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{/* Honeypot: hidden from people, filled in by bots */}
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
<button type="submit" disabled={status.state === "sending"}>
{status.state === "sending" ? "Sending…" : "Send"}
</button>
<p role="status" aria-live="polite">
{status.state === "sent" && "Thanks! Your message is on its way."}
{status.state === "error" && status.message}
</p>
</form>
);
}Render <ContactForm /> from any page; the page itself can stay a Server Component. No environment variable is needed because the key isn't secret, but NEXT_PUBLIC_EMBEDIO_FORM works too if you prefer one per environment.
Sending files? Keep FormData as the body instead of converting to JSON, name the file input file, and add the Accept: application/json header yourself. Uploads need the Uploads module and are capped at 10MB per file.
Spam and validation
- Honeypot: a hidden
_gotchafield. People never see it; bots that fill it get a normal-looking success and nothing is stored. - Double clicks: an identical submission seconds after the first is dropped instead of stored twice.
- Trust level: each form has a relaxed, balanced or strict setting for bursts and repeated content. Flagged submissions are kept aside for review, not deleted.
- Server-side rules: validations such as required, email or max length run on embedio, so they hold even if someone skips your front end.
Getting notified
Every submission shows up in your dashboard straight away. To also get an email for each one, add an Email connection to the form; that works on every plan, including Free. Slack, Discord, Zapier, webhooks and the other connections come with any paid plan, from $9/mo.
Errors you might see
With a JSON request, every failure comes back as a status code plus { "error": "..." }. A plain HTML post gets a readable error page instead.
| Status | error | What it means |
|---|---|---|
| 404 | form_not_found | The form key is wrong or the form was deleted. |
| 422 | validation_failed | A validation rule failed; fields lists { field, message } for each one. |
| 402 | monthly_limit_reached | The account used its submissions for the month. |
| 413 | file_too_large | An uploaded file is over 10MB. |
| 400 | invalid_file_type | The file type isn't accepted (images, PDF, Office documents, TXT and CSV are). |
| 429 | More than 20 submissions a minute from one IP address. Wait and retry. |
The full list, including module-specific errors, is in the API reference.