smithery.ai

contact-form

Build production-ready contact forms with React Hook Form, Zod validation, Server Actions, and email services like Resend.

First seen Apr 17, 2026

Installation

$ npx skills add https://smithery.ai

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery.ai · top by installs.

npx skills add https://smithery.ai

Browse all from smithery.ai

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 20,835 B
  • docs SUMMARY.md 142 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 1 installs

SKILL.md

Contact Form Implementation

Overview

A production contact form requires:

  1. Type-safe validation (Zod)
  2. Efficient form state (React Hook Form)
  3. Server-side processing (Server Actions)
  4. Email delivery (Resend/SendGrid)
  5. Rate limiting (Upstash Redis)
  6. User feedback (Toast notifications)

Dependencies

npm install react-hook-form @hookform/resolvers zod resend
npm install sonner  # Toast notifications
npm install @upstash/ratelimit @upstash/redis  # Rate limiting (optional)

Form Schema

// 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[]>>;
};

Server Action

// 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 <[email protected]>",
      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 <[email protected]>",
      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.",
    };
  }
}

Contact Form Component

// 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="[email protected]"
          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>
  );
}

Toast Provider Setup

// 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>
  );
}

Contact Section

// 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:[email protected]"
                    className="text-zinc-400 hover:text-brand-400"
                  >
                    [email protected]
                  </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>
  );
}

Email Templates with React Email

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;

Environment Variables

# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxx
[email protected]

# Upstash Redis (for rate limiting)
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxxxxxxxxxxxx

Testing Checklist

  • Form validates on blur
  • Server validation catches bypassed client validation
  • Honeypot rejects bots silently
  • Rate limiting works (test with multiple submissions)
  • Email sends correctly
  • Auto-reply sends (if implemented)
  • Success state displays properly
  • Error messages are clear and actionable
  • Form is accessible (labels, ARIA, keyboard nav)
  • Loading state prevents double submission