Back to Posts

How to Build a Secure Contact Form with Rate Limiting | Next.js Guide

How to Build a Secure Contact Form with Rate Limiting | Next.js Guide
15 min read

Learn how to build a production-ready contact form with Next.js, TypeScript, and PostgreSQL. Includes rate limiting, spam protection, security best practices, and admin dashboard implementation.

Introduction

Contact forms are a fundamental feature of modern websites, serving as the bridge between visitors and business owners. However, building a secure, production-ready contact system requires more than just a simple HTML form. In this comprehensive guide, we'll walk through building a complete contact form system with database storage, rate limiting, security measures, and an admin dashboard using Next.js, TypeScript, and PostgreSQL.

Whether you're a full-stack developer looking to implement a contact system for your portfolio, a business owner wanting to understand the technical requirements, or a developer learning modern web security practices, this tutorial covers everything you need to know.

Table of Contents

1. System Architecture Overview

2. Database Schema Design

3. Implementing Rate Limiting

4. Security Best Practices

5. Building the API Route

6. Creating the Frontend Form

7. Admin Dashboard Implementation

8. Testing and Deployment

9. Conclusion

System Architecture Overview

A robust contact form system consists of several key components:

  • Frontend Form: User-friendly interface with validation
  • API Endpoint: Handles form submissions with security checks
  • Rate Limiting: Prevents spam and abuse
  • Database Storage: Persistent storage of contact submissions
  • Admin Dashboard: View and manage submissions
  • Our implementation uses:

  • Next.js 16 with App Router for the framework
  • TypeScript for type safety
  • PostgreSQL with Drizzle ORM for database management
  • Zod for schema validation
  • React Server Components and Client Components for optimal performance
  • Database Schema Design

    The foundation of any contact system is a well-designed database schema. Let's start by defining our contact table structure.

    Schema Definition

    typescript
    import { pgTable, serial, varchar, timestamp, text, boolean } from 'drizzle-orm/pg-core';
    
    export const contacts = pgTable('contacts', {
      id: serial('id').primaryKey(),
      name: varchar('name', { length: 255 }).notNull(),
      email: varchar('email', { length: 255 }).notNull(),
      message: text('message').notNull(),
      ipAddress: varchar('ip_address', { length: 45 }), // IPv6 support
      read: boolean('read').default(false).notNull(),
      createdAt: timestamp('created_at').defaultNow().notNull(),
      deletedAt: timestamp('deleted_at'), // Soft delete pattern
    });
    
    export type Contact = typeof contacts.$inferSelect;
    export type NewContact = typeof contacts.$inferInsert;

    Key Design Decisions

    1. IP Address Tracking: We store IP addresses (up to 45 characters for IPv6) for rate limiting and security analysis

    2. Read Status: A boolean flag to track whether submissions have been reviewed

    3. Soft Deletes: Using deletedAt instead of hard deletes allows for data recovery and audit trails

    4. Text Field for Messages: Using text instead of varchar allows for longer messages without arbitrary length restrictions

    Database Migration

    sql
    CREATE TABLE IF NOT EXISTS contacts (
      id SERIAL PRIMARY KEY,
      name VARCHAR(255) NOT NULL,
      email VARCHAR(255) NOT NULL,
      message TEXT NOT NULL,
      ip_address VARCHAR(45),
      read BOOLEAN NOT NULL DEFAULT false,
      created_at TIMESTAMP NOT NULL DEFAULT NOW(),
      deleted_at TIMESTAMP
    );
    
    -- Performance indexes
    CREATE INDEX IF NOT EXISTS idx_contacts_created_at ON contacts(created_at DESC);
    CREATE INDEX IF NOT EXISTS idx_contacts_read ON contacts(read) WHERE read = false;
    CREATE INDEX IF NOT EXISTS idx_contacts_deleted_at ON contacts(deleted_at) WHERE deleted_at IS NULL;

    These indexes optimize queries for:

  • Recent submissions (sorted by created_at)
  • Unread messages (filtered by read = false)
  • Active records (excluding soft-deleted entries)
  • Implementing Rate Limiting

    Rate limiting is crucial for preventing spam, abuse, and potential denial-of-service attacks. We'll implement an in-memory rate limiter that's simple yet effective for most use cases.

    Rate Limiting Strategy

    typescript
    // Rate limit: 5 submissions per hour per IP
    const RATE_LIMIT_MAX = 5;
    const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour in milliseconds
    
    // In-memory storage (consider Redis for production at scale)
    const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
    
    function getRateLimitKey(request: NextRequest): string {
      // Get IP address from headers (respects proxies)
      const forwardedFor = request.headers.get('x-forwarded-for');
      const realIp = request.headers.get('x-real-ip');
      const ip = forwardedFor?.split(',')[0]?.trim() || realIp || 'unknown';
      return ip;
    }
    
    function checkRateLimit(ip: string): { allowed: boolean; resetTime?: number } {
      const now = Date.now();
      const record = rateLimitMap.get(ip);
    
      if (!record || now > record.resetTime) {
        // No record or window expired, allow and create new record
        rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
        return { allowed: true };
      }
    
      if (record.count >= RATE_LIMIT_MAX) {
        // Rate limit exceeded
        return { allowed: false, resetTime: record.resetTime };
      }
    
      // Increment count
      record.count++;
      rateLimitMap.set(ip, record);
      return { allowed: true };
    }
    
    // Clean up old entries periodically
    setInterval(() => {
      const now = Date.now();
      for (const [ip, record] of rateLimitMap.entries()) {
        if (now > record.resetTime) {
          rateLimitMap.delete(ip);
        }
      }
    }, RATE_LIMIT_WINDOW);

    Production Considerations

    For high-traffic applications, consider:

  • Redis-based rate limiting: Distributed rate limiting across multiple server instances
  • Token bucket algorithm: More sophisticated rate limiting with burst capacity
  • Per-user rate limiting: Track authenticated users separately from IP-based limiting
  • Rate limiting middleware: Use libraries like express-rate-limit or @upstash/ratelimit
  • Security Best Practices

    Security should be baked into every layer of your contact form system. Here are the essential security measures we implement:

    1. Input Validation with Zod

    typescript
    import { z } from 'zod';
    
    const contactSchema = z.object({
      name: z.string().min(1).max(255).trim(),
      email: z.string().email().max(255).trim().toLowerCase(),
      message: z.string().min(10).max(5000).trim(),
    });

    Why this matters:

  • Prevents SQL injection through parameterized queries
  • Validates email format before processing
  • Limits message length to prevent resource exhaustion
  • Trims whitespace to prevent padding attacks
  • 2. Spam Pattern Detection

    typescript
    const spamPatterns = [
      /http[s]?:\/\//i,  // URLs (often used in spam)
      /<script/i,        // Script tags (XSS attempts)
      /[A-Z]{10,}/,      // Excessive caps (spam indicator)
    ];
    
    const isSpam = spamPatterns.some(pattern => pattern.test(validatedData.message));
    
    if (isSpam) {
      console.warn('Potential spam detected from IP:', ip);
      return NextResponse.json(
        { error: 'Invalid message format' },
        { status: 400 }
      );
    }

    Additional spam prevention options:

  • hCaptcha or reCAPTCHA: Visual challenges for bots
  • Honeypot fields: Hidden form fields that bots fill out
  • Content analysis: Check against known spam patterns
  • Third-party services: Services like Akismet or Cloudflare Turnstile
  • 3. IP Address Handling

    typescript
    function getRateLimitKey(request: NextRequest): string {
      const forwardedFor = request.headers.get('x-forwarded-for');
      const realIp = request.headers.get('x-real-ip');
      const ip = forwardedFor?.split(',')[0]?.trim() || realIp || 'unknown';
      return ip;
    }

    Important considerations:

  • Always check X-Forwarded-For when behind a proxy (Vercel, Cloudflare, etc.)
  • Take the first IP if multiple are present (original client)
  • Handle IPv6 addresses (up to 45 characters)
  • Never trust client-provided headers alone
  • 4. Error Handling

    typescript
    try {
      // ... validation and processing
    } catch (error) {
      if (error instanceof z.ZodError) {
        return NextResponse.json(
          { error: 'Validation error', details: error.errors },
          { status: 400 }
        );
      }
      console.error('Error creating contact submission:', error);
      return NextResponse.json(
        { error: 'Failed to submit message. Please try again later.' },
        { status: 500 }
      );
    }

    Security-conscious error handling:

  • Never expose internal errors to clients
  • Log detailed errors server-side for debugging
  • Return generic error messages to prevent information leakage
  • Use appropriate HTTP status codes
  • Building the API Route

    Now let's put it all together in a complete API route:

    typescript
    import { NextRequest, NextResponse } from 'next/server';
    import { db } from '@/lib/db/drizzle';
    import { contacts } from '@/lib/db/schema';
    import { z } from 'zod';
    
    const contactSchema = z.object({
      name: z.string().min(1).max(255).trim(),
      email: z.string().email().max(255).trim().toLowerCase(),
      message: z.string().min(10).max(5000).trim(),
    });
    
    // Rate limiting implementation
    const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
    const RATE_LIMIT_MAX = 5;
    const RATE_LIMIT_WINDOW = 60 * 60 * 1000;
    
    function getRateLimitKey(request: NextRequest): string {
      const forwardedFor = request.headers.get('x-forwarded-for');
      const realIp = request.headers.get('x-real-ip');
      return forwardedFor?.split(',')[0]?.trim() || realIp || 'unknown';
    }
    
    function checkRateLimit(ip: string): { allowed: boolean; resetTime?: number } {
      const now = Date.now();
      const record = rateLimitMap.get(ip);
    
      if (!record || now > record.resetTime) {
        rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
        return { allowed: true };
      }
    
      if (record.count >= RATE_LIMIT_MAX) {
        return { allowed: false, resetTime: record.resetTime };
      }
    
      record.count++;
      rateLimitMap.set(ip, record);
      return { allowed: true };
    }
    
    // Cleanup old entries
    setInterval(() => {
      const now = Date.now();
      for (const [ip, record] of rateLimitMap.entries()) {
        if (now > record.resetTime) {
          rateLimitMap.delete(ip);
        }
      }
    }, RATE_LIMIT_WINDOW);
    
    export async function POST(request: NextRequest) {
      try {
        // Rate limiting
        const ip = getRateLimitKey(request);
        const rateLimit = checkRateLimit(ip);
    
        if (!rateLimit.allowed) {
          const resetTime = rateLimit.resetTime 
            ? new Date(rateLimit.resetTime).toISOString() 
            : 'unknown';
          return NextResponse.json(
            { 
              error: 'Rate limit exceeded. Please try again later.',
              resetTime 
            },
            { 
              status: 429,
              headers: {
                'Retry-After': String(Math.ceil((rateLimit.resetTime! - Date.now()) / 1000)),
              }
            }
          );
        }
    
        // Validate request body
        const body = await request.json();
        const validatedData = contactSchema.parse(body);
    
        // Spam detection
        const spamPatterns = [
          /http[s]?:\/\//i,
          /<script/i,
          /[A-Z]{10,}/,
        ];
    
        const isSpam = spamPatterns.some(pattern => pattern.test(validatedData.message));
    
        if (isSpam) {
          console.warn('Potential spam detected from IP:', ip);
          return NextResponse.json(
            { error: 'Invalid message format' },
            { status: 400 }
          );
        }
    
        // Insert contact into database
        const newContact = await db
          .insert(contacts)
          .values({
            name: validatedData.name,
            email: validatedData.email,
            message: validatedData.message,
            ipAddress: ip !== 'unknown' ? ip : null,
            read: false,
          })
          .returning();
    
        return NextResponse.json(
          { 
            message: 'Thank you for your message! I\'ll get back to you soon.',
            contact: { id: newContact[0].id }
          },
          { status: 201 }
        );
      } catch (error) {
        if (error instanceof z.ZodError) {
          return NextResponse.json(
            { error: 'Validation error', details: error.errors },
            { status: 400 }
          );
        }
        console.error('Error creating contact submission:', error);
        return NextResponse.json(
          { error: 'Failed to submit message. Please try again later.' },
          { status: 500 }
        );
      }
    }

    Key Features

    1. HTTP 429 Status: Proper rate limiting response with Retry-After header

    2. Parameterized Queries: Drizzle ORM automatically uses parameterized queries, preventing SQL injection

    3. Comprehensive Error Handling: Different error types return appropriate status codes

    4. User-Friendly Messages: Success and error messages guide users appropriately

    Creating the Frontend Form

    A good contact form balances user experience with security. Here's a React component that handles form submission:

    typescript
    "use client";
    
    import { useState } from "react";
    import { Button } from "@/components/ui/button";
    import { Input } from "@/components/ui/input";
    import { Textarea } from "@/components/ui/textarea";
    import { Label } from "@/components/ui/label";
    import { Send, Loader2 } from "lucide-react";
    
    export default function ContactForm() {
      const [formData, setFormData] = useState({
        name: "",
        email: "",
        message: "",
      });
      const [isSubmitting, setIsSubmitting] = useState(false);
      const [submitStatus, setSubmitStatus] = useState<{
        type: "success" | "error" | null;
        message: string;
      }>({ type: null, message: "" });
    
      const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setIsSubmitting(true);
        setSubmitStatus({ type: null, message: "" });
    
        try {
          const response = await fetch("/api/contact", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify(formData),
          });
    
          const data = await response.json();
    
          if (!response.ok) {
            throw new Error(data.error || "Failed to send message");
          }
    
          setSubmitStatus({
            type: "success",
            message: data.message || "Thank you for your message!",
          });
          setFormData({ name: "", email: "", message: "" });
        } catch (error) {
          setSubmitStatus({
            type: "error",
            message: error instanceof Error 
              ? error.message 
              : "Failed to send message. Please try again.",
          });
        } finally {
          setIsSubmitting(false);
        }
      };
    
      const handleChange = (
        e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
      ) => {
        setFormData({
          ...formData,
          [e.target.name]: e.target.value,
        });
      };
    
      return (
        <form onSubmit={handleSubmit} className="space-y-6">
          <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
            <div className="space-y-2">
              <Label htmlFor="name">Name</Label>
              <Input
                id="name"
                name="name"
                type="text"
                required
                value={formData.name}
                onChange={handleChange}
                placeholder="Your name"
                disabled={isSubmitting}
              />
            </div>
            <div className="space-y-2">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                name="email"
                type="email"
                required
                value={formData.email}
                onChange={handleChange}
                placeholder="your.email@example.com"
                disabled={isSubmitting}
              />
            </div>
          </div>
          <div className="space-y-2">
            <Label htmlFor="message">Message</Label>
            <Textarea
              id="message"
              name="message"
              required
              minLength={10}
              maxLength={5000}
              value={formData.message}
              onChange={handleChange}
              placeholder="Tell me about your project..."
              disabled={isSubmitting}
              className="min-h-32"
            />
            <p className="text-xs text-muted-foreground">
              {formData.message.length}/5000 characters
            </p>
          </div>
          {submitStatus.type && (
            <div
              className={`p-4 rounded-lg ${
                submitStatus.type === "success"
                  ? "bg-green-500/10 border border-green-500/50 text-green-400"
                  : "bg-red-500/10 border border-red-500/50 text-red-400"
              }`}
            >
              {submitStatus.message}
            </div>
          )}
          <Button
            type="submit"
            size="lg"
            disabled={isSubmitting}
            className="w-full"
          >
            {isSubmitting ? (
              <>
                <Loader2 className="mr-2 w-5 h-5 animate-spin" />
                Sending...
              </>
            ) : (
              <>
                <Send className="mr-2 w-5 h-5" />
                Send Message
              </>
            )}
          </Button>
        </form>
      );
    }

    UX Best Practices

    1. Real-time Validation: Client-side validation provides immediate feedback

    2. Character Counter: Visual feedback for message length limits

    3. Loading States: Disable form during submission to prevent double-submission

    4. Clear Error Messages: User-friendly error messages from API responses

    5. Success Feedback: Clear confirmation when submission succeeds

    6. Form Reset: Clear form after successful submission

    Admin Dashboard Implementation

    An admin dashboard allows you to view, manage, and respond to contact submissions. Here's a complete implementation:

    API Route for Fetching Contacts

    typescript
    // app/api/contacts/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    import { getContacts } from '@/lib/db/queries';
    import { getUser } from '@/lib/db/queries';
    
    export async function GET(request: NextRequest) {
      try {
        const user = await getUser();
        if (!user) {
          return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
        }
    
        const contacts = await getContacts();
        return NextResponse.json({ contacts });
      } catch (error) {
        console.error('Error fetching contacts:', error);
        return NextResponse.json(
          { error: 'Failed to fetch contacts' },
          { status: 500 }
        );
      }
    }

    Dashboard Component

    typescript
    "use client";
    
    import { useEffect, useState } from 'react';
    import { Mail, Calendar, User, CheckCheck } from 'lucide-react';
    import { Card } from '@/components/ui/card';
    import { Button } from '@/components/ui/button';
    
    interface Contact {
      id: number;
      name: string;
      email: string;
      message: string;
      ipAddress: string | null;
      read: boolean;
      createdAt: string;
    }
    
    export default function ContactsDashboard() {
      const [contacts, setContacts] = useState<Contact[]>([]);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        fetchContacts();
      }, []);
    
      const fetchContacts = async () => {
        try {
          const response = await fetch('/api/contacts');
          if (!response.ok) throw new Error('Failed to fetch');
          const data = await response.json();
          setContacts(data.contacts);
        } catch (error) {
          console.error('Error:', error);
        } finally {
          setLoading(false);
        }
      };
    
      const handleMarkAsRead = async (id: number) => {
        try {
          const response = await fetch(`/api/contacts/${id}`, {
            method: 'PATCH',
          });
          if (!response.ok) throw new Error('Failed to update');
          setContacts(
            contacts.map((c) => (c.id === id ? { ...c, read: true } : c))
          );
        } catch (error) {
          console.error('Error:', error);
        }
      };
    
      const formatDate = (dateString: string) => {
        return new Date(dateString).toLocaleDateString('en-US', {
          year: 'numeric',
          month: 'short',
          day: 'numeric',
          hour: '2-digit',
          minute: '2-digit',
        });
      };
    
      const unreadCount = contacts.filter((c) => !c.read).length;
    
      return (
        <div className="p-8">
          <h1 className="text-3xl font-bold mb-4">Contact Submissions</h1>
          <p className="text-muted-foreground mb-6">
            {unreadCount > 0 
              ? `${unreadCount} unread message${unreadCount !== 1 ? 's' : ''}`
              : 'All messages read'}
          </p>
    
          {loading ? (
            <div>Loading...</div>
          ) : (
            <div className="grid gap-4">
              {contacts.map((contact) => (
                <Card
                  key={contact.id}
                  className={`p-6 ${
                    !contact.read ? 'border-purple-500/50 bg-purple-500/5' : ''
                  }`}
                >
                  <div className="flex items-start justify-between mb-4">
                    <div>
                      <div className="flex items-center gap-2 mb-2">
                        <User className="w-5 h-5 text-purple-400" />
                        <h2 className="text-xl font-semibold">{contact.name}</h2>
                        {!contact.read && (
                          <span className="px-2 py-1 text-xs bg-purple-500/20 text-purple-400 rounded">
                            New
                          </span>
                        )}
                      </div>
                      <div className="flex items-center gap-4 text-sm text-muted-foreground">
                        <a
                          href={`mailto:${contact.email}`}
                          className="flex items-center gap-1 hover:text-purple-400"
                        >
                          <Mail className="w-4 h-4" />
                          {contact.email}
                        </a>
                        <span className="flex items-center gap-1">
                          <Calendar className="w-4 h-4" />
                          {formatDate(contact.createdAt)}
                        </span>
                      </div>
                    </div>
                  </div>
                  <p className="text-gray-300 mb-4 whitespace-pre-wrap">
                    {contact.message}
                  </p>
                  <div className="flex items-center gap-2 pt-4 border-t">
                    {!contact.read && (
                      <Button
                        onClick={() => handleMarkAsRead(contact.id)}
                        variant="outline"
                      >
                        Mark as Read
                      </Button>
                    )}
                    {contact.read && (
                      <span className="flex items-center gap-2 text-sm text-muted-foreground">
                        <CheckCheck className="w-4 h-4" />
                        Read
                      </span>
                    )}
                    <a href={`mailto:${contact.email}?subject=Re: Your message`}>
                      <Button variant="outline">Reply</Button>
                    </a>
                  </div>
                </Card>
              ))}
            </div>
          )}
        </div>
      );
    }

    Dashboard Features

    1. Unread Count: Visual indicator of unread messages

    2. Mark as Read: Quick action to track reviewed submissions

    3. Reply Integration: Direct mailto links with pre-filled subjects

    4. Visual Distinction: Unread messages highlighted for easy identification

    5. Responsive Design: Works well on desktop and mobile devices

    Testing and Deployment

    Testing Checklist

    Before deploying to production, ensure:

  • ✅ Rate limiting works correctly
  • ✅ Validation rejects invalid inputs
  • ✅ Spam detection catches obvious spam patterns
  • ✅ Error handling provides appropriate responses
  • ✅ Database queries are optimized
  • ✅ Admin authentication is secure
  • ✅ Form UX is smooth and intuitive
  • Deployment Considerations

    1. Environment Variables: Store database connection strings securely

    2. Database Migrations: Run migrations as part of deployment

    3. Monitoring: Set up error tracking (Sentry, LogRocket, etc.)

    4. Backup Strategy: Regular database backups for contact data

    5. Email Notifications: Consider sending email alerts for new submissions

    Production Optimizations

    typescript
    // Example: Using Upstash Redis for distributed rate limiting
    import { Ratelimit } from "@upstash/ratelimit";
    import { Redis } from "@upstash/redis";
    
    const ratelimit = new Ratelimit({
      redis: Redis.fromEnv(),
      limiter: Ratelimit.slidingWindow(5, "1 h"),
    });
    
    export async function POST(request: NextRequest) {
      const ip = getRateLimitKey(request);
      const { success, limit, reset, remaining } = await ratelimit.limit(ip);
      
      if (!success) {
        return NextResponse.json(
          { error: 'Rate limit exceeded' },
          { 
            status: 429,
            headers: {
              'X-RateLimit-Limit': limit.toString(),
              'X-RateLimit-Remaining': remaining.toString(),
              'X-RateLimit-Reset': reset.toString(),
            }
          }
        );
      }
      // ... rest of handler
    }

    Conclusion

    Building a secure contact form system requires careful attention to security, user experience, and scalability. By implementing:

  • Robust database schema with proper indexing
  • Rate limiting to prevent abuse
  • Input validation and spam detection
  • User-friendly frontend with clear feedback
  • Admin dashboard for managing submissions
  • You create a production-ready system that protects your site while providing a great experience for legitimate users.

    Key Takeaways

    1. Security First: Always validate, sanitize, and rate-limit user input

    2. User Experience: Clear feedback and error messages improve trust

    3. Scalability: Design with growth in mind (consider Redis for distributed rate limiting)

    4. Maintainability: Well-structured code makes future improvements easier

    Next Steps

  • Implement email notifications for new submissions
  • Add advanced spam detection (hCaptcha, Akismet)
  • Create analytics dashboard for submission trends
  • Add export functionality for contact data
  • Implement search and filtering in admin dashboard
  • Resources

  • Next.js Documentation
  • Drizzle ORM Documentation
  • Zod Validation Library
  • OWASP Top 10 - Security best practices
  • Rate Limiting Strategies
  • Share this post

    Star Vilaysack

    About Star Vilaysack

    Full-stack software engineer based in Minneapolis, specializing in building production-grade web applications. Passionate about web development, cloud architecture, and creating exceptional user experiences.