How to Add Document Upload to Your Next.js Contact Form
Building a contact form with document upload functionality can significantly enhance user experience by allowing visitors to share attachments, portfolios, or supporting documents. In this comprehensive guide, we'll walk through creating a robust Next.js contact form with secure file upload capabilities.
What You'll Learn
- How to implement file upload in React with proper validation
- Secure file handling with size and type restrictions
- Error handling for file uploads
- Form submission with FormData
- Integration with Static Forms API for seamless form processing
Note: File uploads are available from the Starter plan up ($6/month, or $4/month billed annually), and the whole submission, meaning the file plus the other fields, has to stay under 4.5MB. Free-tier submissions with files will be rejected.
If you want the plain HTML version first, the HTML file upload guide covers the markup, client-side checks and server-side safety this tutorial builds on.
Project Setup
First, let's set up our Next.js project with the necessary dependencies:
npx create-next-app@latest contact-form-upload --typescript --tailwind --eslint
cd contact-form-upload
npm installBuilding the Contact Form Component
Let's create a comprehensive contact form component with document upload functionality:
'use client';
import { useState, useEffect } from 'react';
export default function ContactForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [attachment, setAttachment] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [statusMessage, setStatusMessage] = useState('');
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
// The whole submission must stay under 4.5MB; 4MB leaves room for the other fields
if (file && file.size > 4 * 1024 * 1024) {
setStatus('error');
setStatusMessage('File size must be less than 4MB.');
e.target.value = ''; // Clear the input
return;
}
// File type validation
if (file) {
const allowedTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'application/rtf'
];
if (!allowedTypes.includes(file.type)) {
setStatus('error');
setStatusMessage('Please upload a valid file type (images, PDF, Word documents, or text files).');
e.target.value = '';
return;
}
}
setAttachment(file);
if (status === 'error') {
setStatus('idle');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const formData = new FormData();
formData.append('apiKey', 'your-static-forms-api-key');
formData.append('name', name);
formData.append('email', email);
formData.append('subject', subject);
formData.append('message', message);
formData.append('replyTo', email);
// Add attachment if present
if (attachment) {
formData.append('attachment', attachment);
}
const response = await fetch('https://api.staticforms.dev/submit', {
method: 'POST',
body: formData,
});
if (response.ok) {
setStatus('success');
setStatusMessage('Thank you for your message! We will get back to you soon.');
// Reset form
setName('');
setEmail('');
setSubject('');
setMessage('');
setAttachment(null);
const fileInput = document.getElementById('attachment') as HTMLInputElement;
if (fileInput) fileInput.value = '';
} else {
setStatus('error');
setStatusMessage('Failed to send your message. Please try again.');
}
} catch (error) {
// An oversized upload shows up here as a network error
setStatus('error');
setStatusMessage('The upload failed. Check your connection, or try smaller files.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-6">Contact Us</h2>
{status === 'success' && (
<div className="mb-6 bg-green-50 p-4 rounded-md border border-green-200">
<p className="text-green-700">{statusMessage}</p>
</div>
)}
{status === 'error' && (
<div className="mb-6 bg-red-50 p-4 rounded-md border border-red-200">
<p className="text-red-700">{statusMessage}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block mb-2 font-medium text-gray-700">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="email" className="block mb-2 font-medium text-gray-700">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="subject" className="block mb-2 font-medium text-gray-700">
Subject <span className="text-red-500">*</span>
</label>
<input
type="text"
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
required
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="message" className="block mb-2 font-medium text-gray-700">
Message <span className="text-red-500">*</span>
</label>
<textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
required
rows={5}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="attachment" className="block mb-2 font-medium text-gray-700">
Attachment <span className="text-gray-500 font-normal">(optional)</span>
</label>
<input
type="file"
id="attachment"
onChange={handleFileChange}
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.txt,.rtf"
className="w-full p-3 border border-gray-300 rounded-md file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<p className="text-sm text-gray-500 mt-1">
Max file size: 4MB. Accepted formats: Images, PDF, Word documents, text files.
</p>
{attachment && (
<p className="text-sm text-green-600 mt-1">
Selected: {attachment.name} ({(attachment.size / 1024 / 1024).toFixed(2)} MB)
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className={`w-full py-3 px-6 rounded-md text-white font-medium transition-colors ${
isSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
</div>
);
}Key Features Explained
File Validation
Our form includes comprehensive file validation to ensure security and user experience:
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
// Size validation (4MB, under the 4.5MB submission limit)
if (file && file.size > 4 * 1024 * 1024) {
setStatus('error');
setStatusMessage('File size must be less than 4MB.');
e.target.value = '';
return;
}
// Type validation
const allowedTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'application/rtf'
];
if (file && !allowedTypes.includes(file.type)) {
setStatus('error');
setStatusMessage('Invalid file type.');
e.target.value = '';
return;
}
};FormData for File Uploads
When submitting files, we use FormData instead of JSON to properly handle binary data:
const formData = new FormData();
formData.append('apiKey', 'your-api-key');
formData.append('name', name);
formData.append('email', email);
// ... other fields
if (attachment) {
formData.append('attachment', attachment);
}Environment Variables
Create a .env.local file to store your API keys securely:
NEXT_PUBLIC_STATIC_FORMS_API_KEY=your_api_key_hereThen use it in your component:
formData.append('apiKey', process.env.NEXT_PUBLIC_STATIC_FORMS_API_KEY || '');Setting Up Static Forms
- Visit Static Forms and create an account
- Create a new form and get your API key
- Configure your form settings to accept file uploads (Starter plan and up)
- Add your domain to the allowed origins
Enhanced Security Considerations
Client-Side Validation
While we validate files on the client side, remember that client-side validation is primarily for user experience. Always validate files on the server side as well.
File Type Restrictions
Our form accepts common document and image types:
- Images: JPEG, PNG, GIF, WebP
- Documents: PDF, Word (.doc, .docx), Plain text, RTF
Size Limitations
Static Forms limits the whole submission, the file plus every other field, to 4.5MB. A larger request is refused with HTTP 413 before Static Forms processes it, and in the browser that shows up as a network error rather than a readable message. So the form checks the file against 4MB before upload, which leaves room for the other fields:
if (file && file.size > 4 * 1024 * 1024) {
// Handle oversized file
}Styling with Tailwind CSS
The form uses Tailwind CSS for responsive, modern styling. Key classes include:
file:prefix for styling file input buttonsfocus:ring-2for accessibility-friendly focus statestransition-colorsfor smooth hover effects
Advanced Features
Progress Indicator
For large file uploads, consider adding a progress indicator:
const [uploadProgress, setUploadProgress] = useState(0);
// In your fetch request
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
const progress = (e.loaded / e.total) * 100;
setUploadProgress(progress);
});Multiple File Support
To support multiple files, modify the state and input:
const [attachments, setAttachments] = useState<File[]>([]);
// In JSX
<input
type="file"
multiple
onChange={handleMultipleFileChange}
/>Drag and Drop
Enhance user experience with drag-and-drop functionality:
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
// Process dropped files
};
return (
<div
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
className="border-2 border-dashed border-gray-300 p-6"
>
Drop files here or click to select
</div>
);Testing Your Form
- Test with different file types and sizes
- Verify error messages display correctly
- Ensure form resets after successful submission
- Test on mobile devices for responsive behavior
Common Troubleshooting
File Not Uploading
- Check file size limits
- Verify file type restrictions
- Ensure FormData is used instead of JSON
Validation Errors
- Verify all required fields are filled
- Check file validation logic
Conclusion
Adding document upload functionality to your Next.js contact form enhances user experience and provides valuable functionality for collecting attachments. By implementing proper validation, error handling, and security measures, you can create a robust form that handles file uploads safely and efficiently.
The combination of React's state management, proper file validation, and Static Forms' reliable backend processing creates a seamless experience for both developers and users. Whether you're building a portfolio submission form, support ticket system, or general contact form, these techniques will serve you well.
For more advanced form handling tutorials and Static Forms features, check out our getting started guide and learn about adding contact forms to static sites.
Related Articles
Related Articles
Why JavaScript Can't Set a File Input's Value (and Fixes)
A file input's value can only be set to an empty string, for security. Clear it, assign files with DataTransfer, or keep your own file list and send FormData.
Next.js Server Actions vs a Form Endpoint for Contact Forms
Compare Next.js Server Actions and managed form endpoints with tested contact form code, static export limits, security checks, and deployment trade-offs.
Make a Claude-Generated Website Contact Form Send Email
Fix a Claude-generated website contact form that fakes success. Trace the handler, patch HTML or React, protect secrets, and test the published Artifact.









