Guides Next.js static export
Contact form for a Next.js static export
Set output: "export" and Next.js builds plain files, which means no API routes and no Server Actions to handle a form. Post the form to sendm8 instead. It works as a plain HTML form, or as a client component that shows its own success message.
The simplest version: a plain form
This needs no client JavaScript and works in a Server Component. Change you@example.com to your address.
export default function ContactPage() {
return (
<form action="https://sendm8.com/f/you@example.com" method="POST">
<label>Your email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" required /></label>
<input type="text" name="_gotcha" style={{ display: "none" }} tabIndex={-1} autoComplete="off" />
<input type="hidden" name="_next" value="https://yoursite.com/thanks" />
<button>Send</button>
</form>
);
}Because action is a URL string rather than a function, React leaves it as a normal HTML post, which is exactly what a static export needs.
Staying on the page: a client component
For a success message without a page change, send the form with fetch and ask for JSON.
"use client";
import { useState } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("sending");
const res = await fetch("https://sendm8.com/f/you@example.com", {
method: "POST",
body: new FormData(event.currentTarget),
headers: { Accept: "application/json" },
});
const result = await res.json();
if (result.ok) setStatus("sent");
else { setError(result.error.message); setStatus("idle"); }
}
if (status === "sent") return <p>Thanks! It’s on its way.</p>;
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
{error && <p role="alert">{error}</p>}
<button disabled={status === "sending"}>Send</button>
</form>
);
}Prefer a hook? @sendm8/react gives you useForm and ValidationError with the same API as @formspree/react.
Confirm your address
- The first time anyone submits, sendm8 stores the message and emails
you@example.com(well, your address) one confirmation link. Click it and that message, plus anything that arrived meanwhile, is delivered. After that, submissions just arrive. - Test with
next devbefore exporting: posts from localhost are always allowed. - After
next build, the form inout/works on any static host: GitHub Pages, Cloudflare Pages, S3 or your own server.
Taking file uploads
To accept files, add enctype="multipart/form-data" to the form and use a normal <input type="file">. Each file can be up to 5 MB, with 10 MB and 5 files per submission. Files are stored with the submission, and your notification links to them rather than attaching them, so nobody’s inbox fills up. With the client component, new FormData(event.currentTarget) already includes the files; don’t set a Content-Type header yourself, or the browser can’t add the multipart boundary.
Spam and limits
Every submission is rate-limited, scored and AI-checked. Flagged messages go to a spam folder you can review. For a form that posts with fetch, you can add Cloudflare Turnstile yourself; the spam docs show how.
A no-account endpoint keeps up to 100 submissions a month and delivers by email. Sign in with the same address and the form moves into your account: 1,000 submissions a month, a dashboard inbox with search and CSV export, and Discord, Slack, Telegram or webhook delivery.
Questions
- Can I use API routes or Server Actions with output: "export"?
- No. A static export has no server at runtime, so API routes and Server Actions aren’t available. Posting the form to a form backend such as sendm8 replaces them.
- Does this work with the Pages Router too?
- Yes. Put the same form or client component in a page under pages/. Nothing here depends on the App Router.
- Is there a React hook?
- Yes, @sendm8/react has useForm and ValidationError with the same API as @formspree/react, so existing Formspree React code mostly needs only the form id changed.