ワンクリックで
contact-form
Build production-ready contact forms with React Hook Form, Zod validation, Server Actions, and email services like Resend.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Build production-ready contact forms with React Hook Form, Zod validation, Server Actions, and email services like Resend.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Build accessible portfolio websites following WCAG guidelines with proper semantics, keyboard navigation, screen reader support, and inclusive design patterns.
Implement analytics, error monitoring, and performance tracking with Vercel Analytics, Google Analytics, Sentry, and custom event tracking.
Build server-side APIs using Next.js Server Actions and API routes.
Manage portfolio content with MDX, Contentlayer, or local JSON for projects, blog posts, and dynamic content.
Implement robust dark mode and theming systems with next-themes, CSS custom properties, and Tailwind CSS for seamless user experience.
Deploy applications to production using Vercel CLI commands.
| name | contact-form |
| description | Build production-ready contact forms with React Hook Form, Zod validation, Server Actions, and email services like Resend. |
| author | Jaivish Chauhan @ GDG SSIT |
| version | 1.0.0 |
| url | https://github.com/JaivishChauhan/vibecoding-starter |
A production contact form requires:
npm install react-hook-form @hookform/resolvers zod resend
npm install sonner # Toast notifications
npm install @upstash/ratelimit @upstash/redis # Rate limiting (optional)
// lib/schemas/contact.ts
import { z } from "zod";
/**
* Contact form validation schema.
* @security Validates all input server-side.
*/
export const contactSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(100, "Name is too long")
.regex(/^[a-zA-Z\s'-]+$/, "Name contains invalid characters"),
email: z
.string()
.email("Please enter a valid email")
.max(255, "Email is too long")
.toLowerCase(),
subject: z
.string()
.min(5, "Subject must be at least 5 characters")
.max(200, "Subject is too long"),
message: z
.string()
.min(20, "Message must be at least 20 characters")
.max(5000, "Message is too long"),
// Honeypot field for spam protection
website: z.string().max(0, "Bot detected").optional(),
});
export type ContactFormData = z.infer<typeof contactSchema>;
// Server response type
export type ContactResponse = {
success: boolean;
message: string;
errors?: Partial<Record<keyof ContactFormData, string[]>>;
};
// app/actions/contact.ts
"use server";
import { z } from "zod";
import { Resend } from "resend";
import { headers } from "next/headers";
import { contactSchema, type ContactResponse } from "@/lib/schemas/contact";
// Initialize Resend
const resend = new Resend(process.env.RESEND_API_KEY);
// Optional: Rate limiting with Upstash
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "1 h"), // 5 requests per hour
analytics: true,
});
/**
* Server action to process contact form submissions.
* @security Rate limited, validated, and sanitized.
*/
export async function submitContactForm(
formData: FormData,
): Promise<ContactResponse> {
try {
// Rate limiting
const headersList = await headers();
const ip = headersList.get("x-forwarded-for") ?? "unknown";
const { success: notRateLimited } = await ratelimit.limit(ip);
if (!notRateLimited) {
return {
success: false,
message: "Too many requests. Please try again later.",
};
}
// Parse form data
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
subject: formData.get("subject"),
message: formData.get("message"),
website: formData.get("website"), // Honeypot
};
// Validate
const validatedData = contactSchema.safeParse(rawData);
if (!validatedData.success) {
return {
success: false,
message: "Validation failed",
errors: validatedData.error.flatten().fieldErrors,
};
}
const { name, email, subject, message, website } = validatedData.data;
// Honeypot check (bots fill this hidden field)
if (website && website.length > 0) {
// Silently reject but return success to confuse bots
console.warn("Honeypot triggered:", { ip, name, email });
return { success: true, message: "Message sent successfully!" };
}
// Send email via Resend
const { error } = await resend.emails.send({
from: "Portfolio Contact <contact@yourdomain.com>",
to: process.env.CONTACT_EMAIL!,
replyTo: email,
subject: `[Portfolio] ${subject}`,
text: `
Name: ${name}
Email: ${email}
Subject: ${subject}
Message:
${message}
`,
// Or use React Email template
// react: ContactEmailTemplate({ name, email, subject, message }),
});
if (error) {
console.error("Resend error:", error);
return {
success: false,
message: "Failed to send message. Please try again.",
};
}
// Optional: Send auto-reply
await resend.emails.send({
from: "John Doe <noreply@yourdomain.com>",
to: email,
subject: "Thanks for reaching out!",
text: `Hi ${name},\n\nThanks for your message! I'll get back to you within 24-48 hours.\n\nBest,\nJohn`,
});
return {
success: true,
message: "Message sent successfully! I'll get back to you soon.",
};
} catch (error) {
console.error("Contact form error:", error);
return {
success: false,
message: "An unexpected error occurred. Please try again.",
};
}
}
// components/contact-form.tsx
"use client";
import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Loader2, Send, CheckCircle } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { contactSchema, type ContactFormData } from "@/lib/schemas/contact";
import { submitContactForm } from "@/app/actions/contact";
/**
* Production-ready contact form with validation, animations, and feedback.
* @security Client + Server validation, honeypot, rate limiting.
*/
export function ContactForm() {
const [isPending, startTransition] = useTransition();
const [isSuccess, setIsSuccess] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors, isValid },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
mode: "onBlur", // Validate on blur for better UX
defaultValues: {
name: "",
email: "",
subject: "",
message: "",
website: "", // Honeypot
},
});
const onSubmit = (data: ContactFormData) => {
startTransition(async () => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value || "");
});
const response = await submitContactForm(formData);
if (response.success) {
setIsSuccess(true);
reset();
toast.success(response.message);
} else {
toast.error(response.message);
// Show field-specific errors
if (response.errors) {
Object.entries(response.errors).forEach(([field, messages]) => {
if (messages?.[0]) {
toast.error(`${field}: ${messages[0]}`);
}
});
}
}
});
};
if (isSuccess) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center justify-center rounded-xl border border-green-500/20 bg-green-500/10 p-8 text-center"
>
<CheckCircle className="mb-4 h-12 w-12 text-green-500" />
<h3 className="text-xl font-bold">Message Sent!</h3>
<p className="mt-2 text-zinc-400">
Thanks for reaching out. I'll get back to you soon.
</p>
<button
onClick={() => setIsSuccess(false)}
className="mt-6 text-sm text-brand-400 hover:underline"
>
Send another message
</button>
</motion.div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Honeypot - Hidden from real users */}
<input
{...register("website")}
type="text"
tabIndex={-1}
autoComplete="off"
className="absolute left-[-9999px] top-[-9999px]"
aria-hidden="true"
/>
{/* Name Field */}
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium">
Name <span className="text-red-500">*</span>
</label>
<input
{...register("name")}
type="text"
id="name"
placeholder="John Doe"
className={`
w-full rounded-lg border bg-zinc-900/50 px-4 py-3
text-white placeholder:text-zinc-500
focus:outline-none focus:ring-2
${
errors.name
? "border-red-500 focus:ring-red-500/50"
: "border-zinc-800 focus:ring-brand-500/50"
}
`}
disabled={isPending}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
/>
<AnimatePresence>
{errors.name && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
id="name-error"
className="mt-2 text-sm text-red-500"
role="alert"
>
{errors.name.message}
</motion.p>
)}
</AnimatePresence>
</div>
{/* Email Field */}
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium">
Email <span className="text-red-500">*</span>
</label>
<input
{...register("email")}
type="email"
id="email"
placeholder="john@example.com"
className={`
w-full rounded-lg border bg-zinc-900/50 px-4 py-3
text-white placeholder:text-zinc-500
focus:outline-none focus:ring-2
${
errors.email
? "border-red-500 focus:ring-red-500/50"
: "border-zinc-800 focus:ring-brand-500/50"
}
`}
disabled={isPending}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
<AnimatePresence>
{errors.email && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
id="email-error"
className="mt-2 text-sm text-red-500"
role="alert"
>
{errors.email.message}
</motion.p>
)}
</AnimatePresence>
</div>
{/* Subject Field */}
<div>
<label htmlFor="subject" className="mb-2 block text-sm font-medium">
Subject <span className="text-red-500">*</span>
</label>
<input
{...register("subject")}
type="text"
id="subject"
placeholder="Project inquiry"
className={`
w-full rounded-lg border bg-zinc-900/50 px-4 py-3
text-white placeholder:text-zinc-500
focus:outline-none focus:ring-2
${
errors.subject
? "border-red-500 focus:ring-red-500/50"
: "border-zinc-800 focus:ring-brand-500/50"
}
`}
disabled={isPending}
aria-invalid={!!errors.subject}
/>
<AnimatePresence>
{errors.subject && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mt-2 text-sm text-red-500"
role="alert"
>
{errors.subject.message}
</motion.p>
)}
</AnimatePresence>
</div>
{/* Message Field */}
<div>
<label htmlFor="message" className="mb-2 block text-sm font-medium">
Message <span className="text-red-500">*</span>
</label>
<textarea
{...register("message")}
id="message"
rows={5}
placeholder="Tell me about your project..."
className={`
w-full resize-none rounded-lg border bg-zinc-900/50 px-4 py-3
text-white placeholder:text-zinc-500
focus:outline-none focus:ring-2
${
errors.message
? "border-red-500 focus:ring-red-500/50"
: "border-zinc-800 focus:ring-brand-500/50"
}
`}
disabled={isPending}
aria-invalid={!!errors.message}
/>
<AnimatePresence>
{errors.message && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mt-2 text-sm text-red-500"
role="alert"
>
{errors.message.message}
</motion.p>
)}
</AnimatePresence>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isPending}
className={`
group flex w-full items-center justify-center gap-2
rounded-lg bg-brand-500 px-6 py-3 font-medium text-white
transition-all hover:bg-brand-600
disabled:cursor-not-allowed disabled:opacity-50
`}
>
{isPending ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Sending...
</>
) : (
<>
Send Message
<Send className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</>
)}
</button>
</form>
);
}
// app/layout.tsx
import { Toaster } from "sonner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Toaster
position="bottom-right"
toastOptions={{
style: {
background: "#18181b",
border: "1px solid #27272a",
color: "#fafafa",
},
}}
/>
</body>
</html>
);
}
// components/sections/contact-section.tsx
import { Mail, MapPin, Phone } from "lucide-react";
import { ContactForm } from "@/components/contact-form";
export function ContactSection() {
return (
<section id="contact" className="py-24">
<div className="container">
<div className="mx-auto max-w-4xl">
{/* Header */}
<div className="mb-12 text-center">
<h2 className="font-heading text-3xl font-bold sm:text-4xl">
Get In Touch
</h2>
<p className="mt-4 text-lg text-zinc-400">
Have a project in mind? Let's talk about it.
</p>
</div>
<div className="grid gap-12 lg:grid-cols-[1fr_1.5fr]">
{/* Contact Info */}
<div className="space-y-8">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
<Mail className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium">Email</h3>
<a
href="mailto:hello@johndoe.com"
className="text-zinc-400 hover:text-brand-400"
>
hello@johndoe.com
</a>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
<MapPin className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium">Location</h3>
<p className="text-zinc-400">San Francisco, CA</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-brand-500/10 text-brand-400">
<Phone className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium">Phone</h3>
<a
href="tel:+1234567890"
className="text-zinc-400 hover:text-brand-400"
>
+1 (234) 567-890
</a>
</div>
</div>
{/* Availability Badge */}
<div className="rounded-lg border border-green-500/20 bg-green-500/10 p-4">
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500" />
</span>
<span className="text-sm font-medium text-green-400">
Available for new projects
</span>
</div>
</div>
</div>
{/* Contact Form */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 sm:p-8">
<ContactForm />
</div>
</div>
</div>
</div>
</section>
);
}
npm install @react-email/components
// emails/contact-notification.tsx
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
interface ContactEmailProps {
name: string;
email: string;
subject: string;
message: string;
}
export function ContactNotificationEmail({
name,
email,
subject,
message,
}: ContactEmailProps) {
return (
<Html>
<Head />
<Preview>New contact form submission from {name}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>New Contact Form Submission</Heading>
<Section style={section}>
<Text style={label}>From:</Text>
<Text style={value}>
{name} ({email})
</Text>
</Section>
<Section style={section}>
<Text style={label}>Subject:</Text>
<Text style={value}>{subject}</Text>
</Section>
<Hr style={hr} />
<Section style={section}>
<Text style={label}>Message:</Text>
<Text style={messageText}>{message}</Text>
</Section>
<Hr style={hr} />
<Text style={footer}>
Reply directly to this email to respond to {name}.
</Text>
</Container>
</Body>
</Html>
);
}
// Styles
const main = {
backgroundColor: "#09090b",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
};
const container = {
backgroundColor: "#18181b",
margin: "0 auto",
padding: "20px",
borderRadius: "8px",
border: "1px solid #27272a",
};
const h1 = {
color: "#fafafa",
fontSize: "24px",
fontWeight: "bold",
margin: "0 0 20px",
};
const section = {
marginBottom: "16px",
};
const label = {
color: "#a1a1aa",
fontSize: "12px",
textTransform: "uppercase" as const,
margin: "0 0 4px",
};
const value = {
color: "#fafafa",
fontSize: "16px",
margin: "0",
};
const messageText = {
color: "#fafafa",
fontSize: "14px",
lineHeight: "1.6",
whiteSpace: "pre-wrap" as const,
};
const hr = {
borderColor: "#27272a",
margin: "20px 0",
};
const footer = {
color: "#71717a",
fontSize: "12px",
margin: "0",
};
export default ContactNotificationEmail;
# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxx
CONTACT_EMAIL=your@email.com
# Upstash Redis (for rate limiting)
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxxxxxxxxxxxx