Send Emails with Form and File Attachments using Next.js, React Hook Form, Zod, and Nodemaile
I have created a guide which will help you send emails using Next.js and and Nodemailer along with attachments you can refer the guide…

Send emails with Forms and File Attachments using Next.js, React Hook Form, Zod, and Nodemailer
I have created a guide which will help you send emails using Next.js and and Nodemailer along with attachments you can refer the guide below and find relevant code snippets for configuring nodemailer with Next.js to send attachments or you can directly refer my GitHub respository at the end of this section.
Project Setup and Dependencies
npx create-next-app@latest my-contact-form --typescript --tailwind --eslint
Now install the following
npm install react-hook-form zod @hookform/resolvers nodemailer class-variance-authority clsx tailwind-merge
I have used [shadcn/ui](https://ui.shadcn.com/docs/components/form) and Zod (this is optional if you dont want to use it skip this part) for beautiful and accessible UI components. Initialize shadcn/ui and select the components you need (e.g., button, card, form, input, textarea):
Bash
npx shadcn-ui@latest init
npx shadcn-ui@latest add button card form input textarea
2. Define the Attachment Schema
To handle file attachments, we need to define their structure. In contact-form.tsx, I have used Zod to create a schema for attachment data, you can your preferred schema validation:
TypeScript
// contact-form.tsx
import { z } from "zod";
// Define the Zod schema for attachment data
const attachmentSchema = z.object({
filename: z.string(),
content: z.string(), // Base64 string
contentType: z.string(), // MIME type
});
// Define the Zod schema for form validation, now including optional attachments
const formSchema = z.object({
email: z.string().email({ message: "Invalid email address." }), // Email validation
message: z.string().min(2, { message: "Message must be at least 2 characters." }), // Message length validation
attachments: z.array(attachmentSchema).optional(), // Optional array of attachments
});
This schema ensures that each attachment has a filename, content (as a Base64 string), and contentType (MIME type). The formSchema then includes an optional array of these attachmentSchema objects.
3. The Contact Form Component
This is where the magic happens on the client-side. The ContactForm component handles user input, file selection, and form submission.
contact-form.tsx
// contact-form.tsx
"use client";
import { cn } from "@/lib/utils"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "./ui/button"
import { Textarea } from "./ui/textarea"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { useState } from "react";
import { sendEmail } from "@/actions/sendEmail"; // Import the server action
// ... (attachmentSchema and formSchema definitions as above)
export function ContactForm({
className,
...props
}: React.ComponentProps<"div">) {
const [isLoading, setIsLoading] = useState(false); // State for loading indicator
const [submissionMessage, setSubmissionMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); // State for submission message
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); // State to hold selected File objects
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), // Connects Zod schema to react-hook-form
defaultValues: {
email: "", // Default empty string for email
message: "", // Default empty string for message
attachments: [], // Initialize attachments as an empty array
},
});
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { // Handler for file input change
if (event.target.files) {
const filesArray = Array.from(event.target.files);
// Basic file size check (e.g., 5MB limit per file)
const maxFileSize = 5 * 1024 * 1024; // 5 MB
const validFiles = filesArray.filter(file => { // Filter valid files
if (file.size > maxFileSize) {
setSubmissionMessage({ type: 'error', text: `File "${file.name}" is too large (max 5MB).` }); // Set error message for large files
return false;
}
return true;
});
setSelectedFiles(validFiles); // Update selected files state
setSubmissionMessage(null); // Clear previous file size errors
}
};
async function onSubmit(values: z.infer<typeof formSchema>) { // Function to handle form submission
setIsLoading(true); // Set loading to true when submission starts
setSubmissionMessage(null); // Clear previous messages
let attachmentsToSend: { filename: string; content: string; contentType: string }[] = [];
// Process selected files into Base64
if (selectedFiles.length > 0) {
const filePromises = selectedFiles.map(file => {
return new Promise<z.infer<typeof attachmentSchema>>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// Extract base64 string (remove data:image/png;base64, prefix)
const base64Content = (reader.result as string).split(',')[1];
resolve({
filename: file.name,
content: base64Content,
contentType: file.type,
});
};
reader.onerror = error => reject(error);
reader.readAsDataURL(file); // Read file as Data URL (Base64)
});
});
try {
attachmentsToSend = await Promise.all(filePromises);
} catch (fileError) {
console.error("Error reading files:", fileError); // Log error if file reading fails
setSubmissionMessage({ type: 'error', text: "Failed to read one or more files." }); // Set error message
setIsLoading(false); // Stop loading
return; // Stop submission if file reading fails
}
}
try {
// Call the server action to send the email, including attachments
const result = await sendEmail({
email: values.email,
message: values.message,
attachments: attachmentsToSend, // Pass the processed attachments
});
if (result.success) {
setSubmissionMessage({ type: 'success', text: result.message }); // Set success message
form.reset(); // Clear form fields on success
setSelectedFiles([]); // Clear selected files state
} else {
setSubmissionMessage({ type: 'error', text: result.message }); // Set error message
}
} catch (error) {
console.error("Error submitting form:", error); // Log submission error
setSubmissionMessage({ type: 'error', text: "An unexpected error occurred." }); // Set unexpected error message
} finally {
setIsLoading(false); // Set loading to false after submission attempt
}
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="rounded-xl shadow-lg">
<CardHeader>
<CardTitle className="text-2xl font-bold text-gray-800">Email and Message</CardTitle>
<CardDescription className="text-gray-600">
Enter your email and your message below. We will get back to you as soon as possible.
</CardDescription>
</CardHeader>
<CardContent className="p-6 pt-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-700 font-medium">Email</FormLabel>
<FormControl>
<Input
id="email"
type="email"
placeholder="m@example.com"
className="rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500"
{...field}
/>
</FormControl>
<FormMessage className="text-red-500 text-sm" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-700 font-medium">Your Message</FormLabel>
<FormControl>
<Textarea
id="message"
placeholder="Type your message here."
className="min-h-[100px] rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500"
{...field}
/>
</FormControl>
<FormMessage className="text-red-500 text-sm" />
</FormItem>
)}
/>
<FormItem>
<FormLabel className="text-gray-700 font-medium">Attachments (Optional)</FormLabel>
<FormControl>
<Input
id="attachments"
type="file"
multiple
onChange={handleFileChange}
className="rounded-md border-gray-300 focus:border-gray-300 focus:ring-gray-500"
/>
</FormControl>
{selectedFiles.length > 0 && (
<div className="mt-2 text-sm text-gray-600">
Selected files: {selectedFiles.map(file => file.name).join(', ')}
</div>
)}
<FormMessage className="text-red-500 text-sm" />
<p className="text-sm text-gray-500">Max file size per attachment: 5MB. Total email size limits apply.</p>
</FormItem>
{submissionMessage && (
<div
className={cn(
"p-3 rounded-md text-sm",
submissionMessage.type === 'success' ? "bg-green-100 text-green-700 border border-green-200" : "bg-red-100 text-red-700 border border-red-200"
)}
>
{submissionMessage.text}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Sending..." : "Submit"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
)
}
Key parts of contact-form.tsx:
**useStatehooks:** Manage loading state (isLoading), submission messages (submissionMessage), and selected files (selectedFiles).**useForm:** Initializes React Hook Form, linking it to the Zod schema for validation.**handleFileChange:** This function captures selected files from the input. It performs a basic client-side size validation (5MB per file) and updates theselectedFilesstate.**onSubmit:** This asynchronous function is the core of the submission logic:- It sets the loading state and clears previous messages.
- It iterates through
selectedFiles, reads each file as a Data URL (Base64), and extracts thebase64Content. This is crucial for sending files via email. - It calls the
sendEmailserver action (which we'll define next) with the email, message, and the processed attachments. - It handles success and error messages, resetting the form and clearing selected files on successful submission.
**Form,FormField,FormItem,FormLabel,FormControl,FormMessage:** These areshadcn/uicomponents that integrate with React Hook Form to provide accessible and well-styled form elements with built-in validation message display.- File Input: The
Inputcomponent withtype="file"andmultipleattribute allows users to select multiple files. TheonChangeprop is linked tohandleFileChange. - Dynamic UI Feedback: The submission message and button text (
Sending...vs.Submit) change based on theisLoadingandsubmissionMessagestates, providing clear feedback to the user.
4. The Server Action: Sending Emails with Nodemailer (sendEmail.ts)
This is a Next.js Server Action, which means it runs exclusively on the server, making it secure for handling sensitive operations like sending emails. Create a new directory actions in your project root and a file sendEmail.ts inside it.
// actions/sendEmail.ts
"use server";
import nodemailer from "nodemailer";
/**
* Interface for attachment data.
*/
interface Attachment {
filename: string;
content: string; // Base64 encoded string of the file content
contentType: string; // MIME type of the file (e.g., 'application/pdf', 'image/png')
}
/**
* Sends an email using Nodemailer, now supporting attachments.
*
* @param {object} params - The parameters for sending the email.
* @param {string} params.email - The sender's email address (from the form).
* @param {string} params.message - The message content from the form.
* @param {Attachment[]} [params.attachments] - Optional array of attachment objects.
* @returns {Promise<{success: boolean, message: string}>} An object indicating success and a message.
*/
export async function sendEmail({
email,
message,
attachments, // New parameter for attachments
}: {
email: string;
message: string;
attachments?: Attachment[]; // Make attachments optional
}) {
// Retrieve environment variables for SMTP configuration
const smtpUsername = process.env.SMTP_USERNAME;
const smtpPassword = process.env.SMPT_PASSWORD;
const mailReceiverAddress = process.env.MAIL_RECIEVER_ADDRESS;
// Basic validation for environment variables
if (!smtpUsername || !smtpPassword || !mailReceiverAddress) {
console.error("Missing SMTP environment variables."); // Log error for missing variables
return { success: false, message: "Server configuration error: Missing email credentials." }; // Return error message
}
try {
// Create a Nodemailer transporter using SMTP
const transporter = nodemailer.createTransport({
host: "smtp.gmail.com", // Example: for Gmail, use smtp.gmail.com
port: 587, // Standard secure SMTP port
secure: false, // Use 'true' if port is 465, 'false' for 587 with STARTTLS
auth: {
user: smtpUsername, // Your Gmail address
pass: smtpPassword, // Your App Password (if using Gmail with 2FA)
},
});
// Prepare Nodemailer attachments array
const nodemailerAttachments = attachments?.map(att => ({
filename: att.filename,
content: att.content, // Nodemailer expects Base64 content here
contentType: att.contentType,
encoding: 'base64', // Specify that the content is base64 encoded
})) || [];
// Define the email options
const mailOptions = {
from: smtpUsername, // Sender address (your email)
to: mailReceiverAddress, // Recipient address (where you want to receive messages)
replyTo: email, // Set the reply-to address to the user's email
subject: `New message from contact form: ${email}`, // Subject of the email
text: `Sender Email: ${email}\n\nMessage:\n${message}`, // Plain text body
html: `
<div style="font-family: sans-serif; line-height: 1.6;">
<p>You have received a new message from your contact form.</p>
<p><strong>Sender Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p style="border: 1px solid #eee; padding: 10px; border-radius: 5px; background-color: #f9f9f9;">${message}</p>
${nodemailerAttachments.length > 0 ? `<p><strong>Attachments:</strong> ${nodemailerAttachments.map(a => a.filename).join(', ')}</p>` : ''}
</div>
`, // HTML body
attachments: nodemailerAttachments, // Add the attachments here
};
// Send the email
await transporter.sendMail(mailOptions);
console.log("Email sent successfully."); // Log success message
return { success: true, message: "Your message has been sent successfully!" }; // Return success object
} catch (error) {
console.error("Error sending email:", error); // Log error message
return { success: false, message: "Failed to send your message. Please try again later." }; // Return error object
}
}
Key aspects of sendEmail.ts:
**"use server"directive:** This is a Next.js specific directive that marks the file (or a function within it) to be executed only on the server. This is crucial for security as it prevents sensitive information (like API keys or SMTP passwords) from being exposed on the client.**nodemailer.createTransport:** Configures Nodemailer to use SMTP (Simple Mail Transfer Protocol). We're using Gmail's SMTP server as an example.- Environment Variables: Crucially, it uses
process.env.SMTP_USERNAME,process.env.SMPT_PASSWORD, andprocess.env.MAIL_RECIEVER_ADDRESS. Never hardcode these directly in your code. **attachmentshandling:** Theattachmentsarray passed from the client is mapped into a format that Nodemailer understands. Thecontentis expected to bebase64andencoding: 'base64'explicitly tells Nodemailer how to interpret it.**mailOptions:** Defines the sender, recipient, subject, and both plain text and HTML bodies of the email.**transporter.sendMail:** Sends the email. Error handling is included to catch any issues during the sending process.
5. Integrating into Your Page (page.tsx)
Finally, include the ContactForm component in your main page:
app/page.tsx
import { ContactForm } from "@/components/contact-form";
export default function Home() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<ContactForm />
</div>
</div>
);
}
6. Environment Variables Setup (.env.local)
Create a .env.local file in your project root and add your SMTP credentials:
SMTP_USERNAME=your-gmail-email@gmail.com
SMPT_PASSWORD=your-google-app-password
MAIL_RECIEVER_ADDRESS=your-receiving-email@example.com
Important Security Note: The SMPT_PASSWORD is often an "App password" if you have 2-Factor Authentication enabled on your Google account (which you absolutely should!). You cannot use your regular Google account password directly if 2FA is on.
How to Get an SMTP Password (Google App Password)
If you are using Gmail please have 2-Step Verification enabled (highly recommended for security reasons), you cannot use your regular Gmail password directly with Nodemailer. Instead, you need to generate an “App password.”
Here’s how to get one:
- Go to your Google Account: Visit myaccount.google.com.
- Navigate to Security: In the left navigation panel, click on
Security. - App passwords: Under “How you sign in to Google,” select
App passwords. You might need to sign in again. - Note: If you don’t see “App passwords,” it might be because:
- 2-Step Verification is not set up for your account.
- Your account is through work, school, or other organization (check with your administrator).
- You have Advanced Protection enabled.
- Generate New App Password:
- From the “Select app” dropdown, choose
Mail. - From the “Select device” dropdown, choose
Other (Custom Name)and give it a name like "Contact Form App". - Click
Generate. - Save the Password: A 16-character code will be displayed in a yellow bar. This is your App password. Copy this code immediately, as you won’t be able to see it again after you close the window.
- Use in
.env.local: Use this 16-character code as yourSMPT_PASSWORDin your.env.localfile.
7. Run Your Application
npm run dev
Visit http://localhost:3000 in your browser, and you should see your contact form ready to go!
Conclusion
You now have a fully functional contact form with file attachment capabilities in your Next.js application. By combining the power of React Hook Form for client-side form management, Zod for robust validation, and Nodemailer (via Next.js Server Actions) for secure server-side email sending, you’ve built a robust and user-friendly communication channel.
Feel free to customize the styling and add more features like reCAPTCHA for spam protection.
Check out the full code on GitHub: [Link to my GitHub Repository]
메타데이터
- post_id
- fcaf6f225afd
- slug
- robust-contact-form-with-file-attachments-using-next-js-react-hook-form-zod-and-nodemaile-fcaf6f225afd
- url
- https://medium.com/@dastagirshaikh/robust-contact-form-with-file-attachments-using-next-js-react-hook-form-zod-and-nodemaile-fcaf6f225afd
- canonical_url
- https://medium.com/@dastagirshaikh/robust-contact-form-with-file-attachments-using-next-js-react-hook-form-zod-and-nodemaile-fcaf6f225afd
- author_url
- https://medium.com/@dastagirshaikh
- status
- ok
- fetched_at
- 2026-06-15 20:49:13