The contact form every Profullstack site uses. One object knows the questions, mints and checks the spam token, validates, sends the email and answers, so the page and the route can never disagree.
npm i @profullstack/contact-form
- Questions you can change. Generic defaults (name, email, message); a site with a custom form passes its own list or adjusts the defaults.
- Spam protection on by default through @profullstack/form-guard: a signed proof-of-render token (the check that catches bots that POST straight at the handler, which a honeypot cannot), a honeypot, a fill-time floor and 5 messages per hour per address. Anything caught is answered like a success, so a bot learns nothing.
- Delivery through Resend (
RESEND_API_KEY), a customsend, or an@profullstack/emailer. The email carries the labelled answers,Reply-Tothe sender, a[spam? N]subject tag when content scoring flags it, and a provenance block (IP, user agent, fill time, signals). A flagged message is still delivered. - Any framework.
POSTtakes a standardRequest(Next App Router, Hono, Bun, Workers, SvelteKit). JSON in gets JSON out; a plain<form>post gets a 303 back to the page with?sent=1or?error=.... - Markup as an HTML string (
render()) or a React component (@profullstack/contact-form/react). Plaincf-*classes, no inline styles, so it works under a strict CSP and takes each site's own CSS.
One module both the page and the route import:
// lib/contact.js
import { createContactForm } from '@profullstack/contact-form';
export const contact = createContactForm({
site: 'example.com', // token binding: "example.com:contact"
to: 'hello@example.com',
from: 'Example <hello@example.com>',
brandTerms: ['example'], // words a scraper echoes back
});The secret is FORM_GUARD_SECRET, or RESEND_API_KEY if that is unset
(sending needs it anyway). It must be the same on every instance serving the
form. With neither set the form logs a warning and keeps only its honeypot.
// app/api/contact/route.js
import { contact } from '@/lib/contact';
export const POST = contact.POST;// app/contact/page.jsx (a server component)
import { ContactForm } from '@profullstack/contact-form/react';
import { contact } from '@/lib/contact';
export const dynamic = 'force-dynamic'; // a token per render, never a cached page
export default async function Page() {
return <ContactForm {...(await contact.props())} />;
}A page that is a client component all the way down can fetch the props:
export GET = contact.GET from the route and render
<ContactForm src="/api/contact" />.
With cacheComponents (Next 16) on, export const dynamic is refused at
build time. Render the form inside <Suspense> from an async component that
calls await connection() (from next/server) before contact.props(), so the
token is minted per request:
async function FormSlot() {
await connection();
return <ContactForm {...(await contact.props())} />;
}
export default function Page() {
return (
<Suspense fallback={null}>
<FormSlot />
</Suspense>
);
}<ContactForm> also takes onSubmit(body) (return false to cancel; for an
analytics "submit" event), onError(message, response), onSent(body, response),
defaultValues, resetLabel and children (shown under the success message).
app.get('/contact', async (c) =>
c.html(layout(await contact.render({ action: '/contact', query: c.req.url }))),
);
app.post('/contact', (c) => contact.POST(c.req.raw));A plain <form> post is answered with a 303 back to the page: ?sent=1, or
?error=<code> (invalid, retry, limited, sendFailed, error) plus
&field=&reason= for a validation failure. The URL never carries free text,
because a page that prints ?error= would print whatever a crafted link held.
render({ query }) turns it back into this form's own message, or call
contact.errorMessage(query) / contact.sentMessage(query) yourself. Never
print the raw query value.
Serve the page with cache-control: no-store: a cached page shares one token
between visitors and expires it for all of them.
A framework that has already parsed the body (Primate's request.body,
Express with body-parser) passes the fields:
contact.handleFields(body, { headers, ip, referer, html: true }). Same
checks, same answers, no Request adapter in the site.
A static page loaded by a client-side router can't mint a token on the
server. Export GET = contact.GET from the route and mount the guard in the
browser:
import { mountGuard } from '@profullstack/contact-form/browser';
await mountGuard(document.querySelector('form.contact'), '/api/contact');It adds the token and the hidden honeypot (class cf-hp, so hide it in your
CSS), and replaces them if you mount again.
The module is ESM and needs Node 20 or later. An app on an older runtime has to upgrade first.
import { createContactForm, withQuestions } from '@profullstack/contact-form';
createContactForm({
to: 'hello@example.com',
questions: withQuestions({
add: [
{ name: 'company', label: 'Company / Project' },
{
name: 'budget',
type: 'select',
options: ['Under $5k', '$5k-$25k', '$25k+'],
required: true,
},
],
override: { message: { label: 'Tell us about the project' } },
}),
});Or a whole list: questions: [{ name: 'email', required: true }, { name: 'idea', type: 'textarea', required: true }].
| Field | |
|---|---|
name |
body key and input name (letters, digits, _, -) |
label |
shown on the form and in the email; default from the name |
type |
text email textarea select url tel number checkbox hidden |
required, maxLength (500, textarea 5000), placeholder, rows, autocomplete, help |
|
options |
for select: strings or { value, label }; the answer must be one of them |
pattern, patternMessage |
a regex source the answer must match |
Only answers to questions reach the email or persist: an extra key in the
body is ignored, so nobody can smuggle fields into what you read.
Since 0.3.0: minLength; pattern must match the whole answer (as in
HTML) and is sent to the input with patternMessage as its title, so both
are checked in the browser too. A select reads as its label in the email.
Messages use the label without a trailing * or :.
Since 0.2.0:
type: 'section'is a heading (label, optionalhelp) between groups of fields. It is never validated or emailed.defaultis the value a field starts with: a select preset (it must be one of the options) or a ticked checkbox (true).- A select's
placeholderbecomes an empty first option that starts selected. On a required select the browser then makes the person choose, instead of sending whichever option is listed first. - Per-render starting values, such as a hidden
reffrom?ref=or a box the link ticked:contact.props({ values: { ref } })orcontact.render({ values }). Only question names are kept. <ContactForm>also takesdefaultValues(these win),children(shown under the success message) andresetLabel(a "Send another" button).
An order form, a build request or a bug report asked for something; the
stock "I have a few questions about pricing, availability, and the general
process of working with you" note answers none of it. requireSubstance
names the questions that must say something. A submission with fewer than
minSubstance (20) characters left after form-guard's substance() is
answered like a success and not delivered (onQuarantine sees it). Never use
it on a general contact form, where "please tell me more" is a fair message.
| Option | Default | |
|---|---|---|
to |
required | who receives the notification |
from |
From header | |
site, binding |
site:contact |
token binding |
questions |
DEFAULT_QUESTIONS |
|
guard |
built from env | form-guard options (minAgeMs, rateLimit, requireToken, ...), a guard instance, or false |
brandTerms |
[] |
content scoring |
subject |
New contact form submission from {name} |
string or function of the submission |
send, emailer, emailerConfig |
Resend from RESEND_API_KEY |
transport |
persist(submission, verdict) |
store it (Supabase etc.); an id it returns is in the response; a throw is a 500 | |
confirmation |
{ subject, html } emailed to the sender |
|
verify(body, req), captcha |
extra checks; captcha: { provider: 'turnstile' | 'hcaptcha', secretKey } |
|
style |
success |
response bodies: success ({success:true}/{error}), ok, message |
responses |
override any response | |
onSendError |
fail |
ignore answers success when the email fails |
onMissingEmailer |
error in production, else log |
|
requireSubstance, minSubstance, onQuarantine |
see above | |
redirect |
the Referer | where a plain form post is sent back to |
action, submitLabel, successMessage, hpClass (cf-hp) |
rendering | |
ip(req) |
from proxy headers | the address, where the platform keeps it elsewhere (Hono c.env.ip) |
FORM_GUARD_ENFORCE=0 scores without blocking, if a real sender is ever
turned away.
BASE_CSS hides .cf-hp and lays the fields out; use it, or style
.cf-form, .cf-field, .cf-error, .cf-notice, .cf-submit yourself. Hide
.cf-hp from your stylesheet whatever you do: a visible honeypot drops the
real people who fill it in.
createContactRoute and createContactGuard are here unchanged (and
@profullstack/stack/email re-exports them from here), so existing routes
keep working. New code uses createContactForm, which also owns the page.
MIT © Profullstack, Inc.