← Back to list

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…

Dastagir Shaikh · 2025-07-16 19:02 · 1 claps · 9.6 min read
#nextjs-15 #nextjs-tutorial #nodemailer #email #automated-email
Open on Medium ↗
Wiki topics: 🌐 · Web Development 💑 · Relationships

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:

  • **useState hooks:** 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 the selectedFiles state.
  • **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 the base64Content. This is crucial for sending files via email.
  • It calls the sendEmail server 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 are shadcn/ui components that integrate with React Hook Form to provide accessible and well-styled form elements with built-in validation message display.
  • File Input: The Input component with type="file" and multiple attribute allows users to select multiple files. The onChange prop is linked to handleFileChange.
  • Dynamic UI Feedback: The submission message and button text (Sending... vs. Submit) change based on the isLoading and submissionMessage states, 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, and process.env.MAIL_RECIEVER_ADDRESS. Never hardcode these directly in your code.
  • **attachments handling:** The attachments array passed from the client is mapped into a format that Nodemailer understands. The content is expected to be base64 and encoding: '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 your SMPT_PASSWORD in your .env.local file.

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