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

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
7. Admin Dashboard Implementation
9. Conclusion
System Architecture Overview
A robust contact form system consists of several key components:
Our implementation uses:
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
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
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:
created_at)read = false)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
// 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:
express-rate-limit or @upstash/ratelimitSecurity 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
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:
2. Spam Pattern Detection
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:
3. IP Address Handling
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:
X-Forwarded-For when behind a proxy (Vercel, Cloudflare, etc.)4. Error Handling
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:
Building the API Route
Now let's put it all together in a complete API route:
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:
"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
// 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
"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:
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
// 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:
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
Resources

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.