Build a contact form in Next.js
A Next.js contact form can post from a Client Component directly to a published LittleReply endpoint. Keep the public key in the browser, keep management credentials server-only, and show the stored-submission result separately from email delivery.
Create a free formA copyable example
'use client';
import { useState, useRef } from 'react';
export default function Contact() {
const [etat, setEtat] = useState('');
const [busy, setBusy] = useState(false);
const tentative = useRef({ contenu: '', id: '' });
async function envoyer(event) {
event.preventDefault();
const form = event.currentTarget;
if (!form.reportValidity()) return;
const data = new FormData(form);
const fields = { name: data.get('name'), email: data.get('email'), message: data.get('message') };
const contenu = JSON.stringify(fields);
if (contenu !== tentative.current.contenu) tentative.current = { contenu, id: crypto.randomUUID() };
setBusy(true); setEtat("Sending…");
try {
const response = await fetch("https://littlereply.com/api/submit", {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': tentative.current.id },
body: JSON.stringify({ form_key: "YOUR_ACCESS_KEY", fields })
});
const result = await response.json();
if (!response.ok) throw new Error(response.status === 429 ? "Limit reached. Try again later." : "Could not submit. Check the fields and try again.");
setEtat("Your message was saved. Email delivery is tracked separately.");
} catch (error) { setEtat(error instanceof Error ? error.message : "Could not submit."); }
finally { setBusy(false); }
}
return <form onSubmit={envoyer}>
<label>Your name<input name='name' autoComplete='name' required /></label>
<label>Your email<input name='email' type='email' autoComplete='email' required /></label>
<label>Your message<textarea name='message' required maxLength={5000} /></label>
<button disabled={busy}>Send message</button>
<p role='status' aria-live='polite'>{etat}</p>
</form>;
}The key YOUR_ACCESS_KEY is a placeholder to replace with your form’s public key. The code contains no server key or destination address.
Choose the smallest boundary
Submit directly when the browser only sends public form fields. Add your own Route Handler only when you need trusted server enrichment or a private integration that cannot run in the browser.
Do not place a management token in a NEXT_PUBLIC variable. The public form key is designed for intake; account and API credentials are not.
Model pending, error, and success states
Disable repeat clicks while the request is pending, keep inputs after recoverable errors, and use an idempotency key for retries of unchanged content.
A 202 response confirms durable intake. Present that state accurately and use the workspace delivery record for email or webhook outcomes.
Verify the deployed host
Test the actual Vercel preview and production hosts against the allowed-origin policy. Check browser errors, the network response, the stored submission, and notification history.
Try the template before creating an account
Local preview: changes stay in this browser. No message or email is sent.
HTML contact
Name, email, and message: the essentials for starting a conversation.
LittleReply<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Contact form</title>
<style>form[data-littlereply]{max-width:640px;margin:2rem auto;padding:1.5rem;font:16px/1.6 system-ui,sans-serif;color:#202033;background:white;border:1px solid #e4e4ee;border-radius:16px;box-sizing:border-box}form[data-littlereply] label{display:block;margin-top:1rem;font-weight:600}form[data-littlereply] input,form[data-littlereply] textarea,form[data-littlereply] select{box-sizing:border-box;width:100%;padding:.75rem;font:inherit;border:1px solid #727285;border-radius:8px;background:white;color:#202033}form[data-littlereply] input[type=checkbox],form[data-littlereply] input[type=radio]{width:auto}form[data-littlereply] button{margin-top:1rem;padding:.8rem 1.1rem;font:inherit;font-weight:600;border:0;border-radius:8px;background:var(--form-accent,#4f46e5);color:white;cursor:pointer}form[data-littlereply] :focus-visible{outline:3px solid #f59e0b;outline-offset:3px}form[data-littlereply] button:disabled{opacity:.6}form[data-littlereply] [role=status]{min-height:1.6em}form[data-littlereply] fieldset{border:0;padding:0}form[data-littlereply] .privacy-notice{font-size:.875rem;color:#4b4b60}
form[data-littlereply]{--form-accent:#4f46e5;--form-ink:#ffffff}
form[data-littlereply] button{color:var(--form-ink)}form[data-littlereply] button[data-fq-back]{background:#eee;color:#202033}form[data-littlereply] [hidden]{display:none}</style></head><body>
<form action="https://littlereply.com/api/submit" method="POST" data-littlereply="YOUR_ACCESS_KEY" data-locale="en" data-success="Your message has been stored. We will reply soon.">
<input type="hidden" name="_fq_key" value="YOUR_ACCESS_KEY" />
<input type="text" name="botcheck" tabindex="-1" autocomplete="off" style="display:none" aria-hidden="true" />
<label for="fq_name">Your name *</label>
<input type="text" name="name" id="fq_name" required autocomplete="name" />
<label for="fq_email">Your email address *</label>
<input type="email" name="email" id="fq_email" required autocomplete="email" />
<label for="fq_message">Your message *</label>
<textarea name="message" id="fq_message" required maxlength="5000" rows="5"></textarea>
<button type="submit">Send my message</button>
<p role="status" aria-live="polite"></p>
</form>
<!-- Accessible AJAX errors and duplicate-safe retries -->
<script src="https://littlereply.com/client/v1.js" data-api="https://littlereply.com" defer></script>
</body></html>Replace YOUR_ACCESS_KEY with your form's public key. The code includes its styles and an AJAX enhancement; the native HTML POST also works without JavaScript.
Sources
Frequently asked questions
Do I need a Server Action?
No. Direct browser submission is enough for public fields. Use a server boundary only for trusted work.
Is the form key secret?
No. It can be present in client code and only authorizes public intake.