hardened-contact-form-nextjs
Skill0 starsChanged 4 months agoMITView on GitHub
A production-hardened contact/enquiry form for Next.js App Router: Zod validation, a silent honeypot, in-memory per-IP rate limiting, mail header-injection safety, request-time env reading (avoids a Vercel bug), graceful config fallback, and an abuse-safe auto-reply. Use when building a contact form, enquiry form, or lead-capture endpoint that must resist spam and abuse. Composes with the resend-email-nextjs and cloudflare-turnstile-nextjs skills.
- Reads credentials
In this repository23 other files it gives its agents.
- client-document-exportSkill
- cloudflare-turnstile-nextjsSkill
- ga4-analytics-nextjsSkill
- git-repo-privacy-auditSkill
- html-semantic-markupSkill
- multi-step-wizard-flowSkill
- nextjs-api-routesSkill
- nextjs-app-routerSkill
- nextjs-seo-metadataSkill
- nextjs-ui-foundationSkill
- next-themes-dark-modeSkill
- playwright-e2e-testingSkill
- react-components-patternsSkill
- react-hooks-patternsSkill
- recharts-dashboardSkill
- resend-email-nextjsSkill
- server-pdf-playwrightSkill
- shadcn-ui-setupSkill
- specialized-form-inputsSkill
- tailwind-css-v4Skill
- vitest-unit-testingSkill
- web-accessibility-wcagSkill
- zustand-persist-stateSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 20
Do not destructure env at module scope in a route handler:
- Very specific: line 26
Read each var inside the handler and keep the !X || !Y shape so TypeScript narrows the type after the guard:
- Very specific: line 81
Honeypot: if website is set, return { ok: true } — *silently accept* so bots get no signal.
- Distinctive: line 95
Put NOTHING user-controlled in the auto-reply: Use a generic greeting ("Hi there,"), not "Hi {name}," — the recipient is attacker-chosen.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Hardened Contact Form (Next.js)
The email sending mechanics live in resend-email-nextjs; bot protection in cloudflare-turnstile-nextjs. This skill is the security layer around them.
Request-time env reading — the Vercel gotcha (read this first)
Do not destructure env at module scope in a route handler:
const { RESEND_API_KEY } = process.env; // ❌ can be undefined at runtime on VercelRead each var inside the handler and keep the !X || !Y shape so TypeScript
narrows the type after the guard:
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const to = process.env.CONTACT_TO_EMAIL;
if (!RESEND_API_KEY || !to) {
const missing = [!RESEND_API_KEY && "RESEND_API_KEY", !to && "CONTACT_TO_EMAIL"].filter(Boolean);
console.error(`Not configured — missing: ${missing.join(", ")}`);
return NextResponse.json({ error: "Temporarily unavailable." }, { status: 503 }); // graceful
}Validation schema (Zod) — CRLF-safe + silent honeypot
const singleLine = (max: number) => z.string().trim().min(1).max(max)
.refine((v) => !/[\r\n]/.test(v), "Line breaks are not allowed"); // blocks header injection
export const contactSchema = z.object({
name: singleLine(100),
email: z.string().trim().email().max(200),
message: z.string().trim().min(20).max(4000),
// Honeypot: MUST stay permissive. If it fails validation, the 400 names the
// field and tells the bot how it was caught. Accept it, then handle in the route.
website: z.string().max(200).optional(),
turnstileToken: z.string().max(4096).optional(),
});In-memory rate limiter (lib/rate-limit.ts)
Fixed-window, dependency-free. Caveat: on serverless it is per-instance and
resets on cold start, so it is a speed bump, not a wall — pair it with Turnstile,
and swap for Upstash Redis (@upstash/ratelimit) if you need real distributed limits.
const buckets = new Map<string, { count: number; resetAt: number }>();
export function rateLimit(key: string, limit: number, windowMs: number) {
const now = Date.now(); const b = buckets.get(key);
if (!b || b.resetAt <= now) { buckets.set(key, { count: 1, resetAt: now + windowMs });
return { success: true, resetAt: now + windowMs }; }
if (b.count >= limit) return { success: false, resetAt: b.resetAt };
b.count++; return { success: true, resetAt: b.resetAt };
}
export function clientIp(h: Headers) {
return h.get("x-forwarded-for")?.split(",")[0].trim() || h.get("x-real-ip") || "unknown";
}Route order (app/api/contact/route.ts)
- Rate limit by IP → 429 with
Retry-After. - Parse JSON in try/catch → 400.
- Validate with the schema → 400 (generic message, no field names leaked).
- Honeypot: if
websiteis set, return{ ok: true }— silently accept so bots get no signal. Send nothing. - Turnstile verify (see cloudflare-turnstile-nextjs) → 403 on failure.
- Config check (request-time env) → 503 if unset.
- Notify the owner:
tois a FIXED env address (never user input). Put the visitor's address inreplyToonly. This makes arbitrary-recipient relay impossible and, with the Zod CRLF guard, blocks header injection. - Auto-reply (optional) — see the abuse note below.
Auto-reply — an abuse vector to neutralize
An acknowledgement email is sent to the address the user typed, so an attacker can aim it at a victim and (if you echo the name) smuggle a short message.
- Put NOTHING user-controlled in the auto-reply. Use a generic greeting ("Hi there,"), not "Hi {name}," — the recipient is attacker-chosen.
- Make it best-effort: a failure here must not fail the enquiry (the owner
notification is what matters). Wrap it in its own try/catch and still return
ok. - The "send details to X" address belongs in an env var (e.g.
CONTACT_DETAILS_EMAIL), never hardcoded — keeps a personal inbox out of a public repo. Set the auto-reply'sReply-Toto a monitored branded inbox. - The real fix for automated abuse is Turnstile (step 5).
Env vars
RESEND_API_KEY=
CONTACT_TO_EMAIL=contact@yourdomain.com # fixed delivery inbox
CONTACT_FROM_EMAIL="Brand <noreply@yourdomain.com>" # verified Resend sender
CONTACT_DETAILS_EMAIL=contact@yourdomain.com # optional, shown in the auto-reply
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=The mistakes this prevents
- Module-scope env destructuring → contact form 503s on Vercel even with keys set.
- User
name/emailin a mail header → CRLF header injection. - User-controlled
to→ the endpoint becomes an open relay. - Honeypot that fails validation → tells bots exactly how it caught them.
- Personalized auto-reply → send a short message to any victim from your domain.
- Hardcoded personal email in the auto-reply → leaked in a public repo.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

