Next.js
Add a contact form to a Next.js site, including static exports, that emails you each submission without an API route or server action.
A contact form on a Next.js site usually means writing an API route or a Server Action, wiring up an email provider and keeping its credentials safe. With SilentForms none of that is needed: the form posts straight from the browser to the submit endpoint, and the submission arrives in your inbox. It works the same on a server-rendered app, on Vercel, and on a fully static export.
What you'll build#
A client component with name, email and message fields that:
- sends the submission with
fetchand stays on the page, - shows a sending state, a success message and readable errors,
- keeps the hidden honeypot field that filters out bots.
You need a Next.js project using the App Router (the Pages Router works too, see the last section) and an access key.
1. Get an access key#
Enter your email on the access key page and click the confirmation link. The key looks like a long hexadecimal string. It only routes submissions to your inbox, so it is safe to ship in client-side code.
2. Add the key to your environment#
Put the key in .env.local so it is not hard-coded in the component:
NEXT_PUBLIC_SILENTFORMS_KEY=YOUR_ACCESS_KEY_HEREThe NEXT_PUBLIC_ prefix is required: Next.js only exposes variables with it to browser code, and it inlines the value at build time. Restart next dev after adding it, and set the same variable in your hosting provider before you deploy.
3. Create the form component#
The form handles its own state, so it has to be a Client Component. Create app/contact/ContactForm.jsx:
'use client';
import { useState } from 'react';
export default function ContactForm() {
const [status, setStatus] = useState('idle'); // idle | sending | sent | error
const [message, setMessage] = useState('');
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
setStatus('sending');
const fields = Object.fromEntries(new FormData(form));
try {
const response = await fetch('https://silentforms.com/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ ...fields, accessKey: process.env.NEXT_PUBLIC_SILENTFORMS_KEY }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.message);
setStatus('sent');
setMessage('Thanks! Your message has been sent.');
form.reset();
} catch (error) {
setStatus('error');
setMessage(error.message || 'Something went wrong. Please try again.');
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required />
{/* Honeypot: hidden from people, filled in by bots */}
<input type="checkbox" name="botcheck" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send message'}
</button>
{message && <p role={status === 'error' ? 'alert' : 'status'}>{message}</p>}
</form>
);
}A few details worth keeping:
Accept: application/jsonmakes the endpoint answer with JSON instead of redirecting, so you can show the result in place.- The error message comes straight from the response. It is written for visitors, for example "Email address is invalid."
- The honeypot checkbox must stay unchecked and hidden. An unchecked box is not sent at all, so real visitors are never caught by it.
4. Use it on a page#
The page itself can stay a Server Component. Create app/contact/page.jsx:
import ContactForm from './ContactForm';
export const metadata = { title: 'Contact' };
export default function ContactPage() {
return (
<main>
<h1>Contact us</h1>
<ContactForm />
</main>
);
}Run next dev, open /contact, send yourself a message and check your inbox.
Static exports#
Nothing in this setup needs a server. With output: 'export' in next.config.js the form works exactly the same, which makes it a good fit for sites hosted on GitHub Pages, Cloudflare Pages or an S3 bucket.
Without JavaScript#
If you prefer a form that works before any JavaScript loads, post it directly and let the browser follow the redirect:
export default function ContactPage() {
return (
<form action="https://silentforms.com/api/submit" method="POST">
<input type="hidden" name="accessKey" value={process.env.NEXT_PUBLIC_SILENTFORMS_KEY} />
<input type="hidden" name="redirect" value="https://example.com/thanks" />
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<button type="submit">Send</button>
</form>
);
}After a successful submission the visitor lands on the URL in redirect, or on the default thank-you page if you leave it out. See Redirect after submit.
Pages Router#
The component above works unchanged in the Pages Router: drop the 'use client' line, keep it in components/ContactForm.jsx and render it from pages/contact.jsx.
Troubleshooting#
- "Access key is required." The environment variable is empty in the browser. Check the
NEXT_PUBLIC_prefix and restart the dev server or rebuild, since the value is inlined at build time. - An error about event handlers in a Server Component. The
'use client'line is missing from the form file. - The submission arrives but replies go to the wrong address. Name the visitor's email field
email, or setreplyto. See Reply-To address.
For every error message and status code, see Troubleshooting.
Next steps#
- Add a CAPTCHA if a form attracts spam: Cloudflare Turnstile.
- Send submissions to Slack, a spreadsheet or your own API with webhooks.
- Lock the form to your domain in the dashboard: Restrict to your domain.