Step-by-step guide for creating a contact form using Next.js and Nodemailer

Most websites use contact pages so visitors can reach and interact with the owner. Having a functional and secure contact form is an important aspect of user interaction, and in this guide, I'll help you build just that; we'll create a contact form using Next.js (Page Router), TypeScript, and Nodemailer, a popular Node.js module for sending emails.

Be sure you have Node.js installed on your machine and your preferred code editor ready. Let's start!

1. Set up a Next.js project

If you haven't already, use the following command in your terminal. Keep in mind that you can replace nextjs-contact-form with a proper name for your project:

npx create-next-app nextjs-contact-form

If this is your first time using the create-next-app, you'll see a message asking you to install it. Type y to accept and proceed.

Next, you'll select the default options for your project. In this example, we'll use Typescript, no ESLint, pure CSS (no Tailwind), no src\ directory, no App Router, and no default import alias customization.

Would you like to use TypeScript? Yes Would you like to use ESLint? No Would you like to use Tailwind CSS? No Would you like to use `src/` directory? No Would you like to use App Router? (recommended) No Would you like to customize the default import alias? No

Of course, you can change this according to your needs, but considering that we'll use the Page Router in this guide, you'll need to customize the code and directories properly if you prefer to use the App Router. After the required project dependencies finish installing, point to the newly created project directory:

cd nextjs-contact-form

2. Install Nodemailer

npm install nodemailer

Since we're using TypeScript in this guide, don't forget to install Nodemailer's type definitions.

npm install @types/nodemailer --save-dev

3. Create the contact form component

Add a components folder in your project's root directory.

mkdir components

Inside components, create a new file named contact-form.tsx. This component will contain your contact form markup and logic. Don't mind the TODO comments; we'll take care of that in the final step (once we have both our endpoint and service method in place).

components/contact-form.tsx

import { FormEvent } from "react"; import contactFormStyles from "./contact-form.module.css"; export default function ContactForm() { // TODO: Save the response message in the component state. function onSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const eventData = new FormData(event.currentTarget); // TODO: Send email. } return ( <> <div className={contactFormStyles.container}> <h1 className={contactFormStyles.header}>Contact Me!</h1> <form onSubmit={onSubmit}> <label htmlFor="name">Name</label> <input type="text" title="Name" name="name" id="name" required className={contactFormStyles.formField} /> <label htmlFor="email">Email</label> <input type="email" title="Email" name="email" id="email" required className={contactFormStyles.formField} /> <label htmlFor="message">Message</label> <textarea rows={10} title="Message" name="message" id="message" required className={contactFormStyles.formField} ></textarea> <input type="submit" title="Send" value="Send" className={contactFormStyles.submitButton} /> </form> </div> </> ); }

Optional: Let's make the form look a bit better. In the same component directory, create a file named contact-form.module.css and add some basic CSS rules for our form.

components/contact-form.module.css

.formField { width: 100%; padding: 12px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; margin-top: 6px; margin-bottom: 16px; resize: vertical; } .submitButton { background-color: #3f51b5; color: #fff; padding: 12px 20px; border: none; border-radius: 4px; cursor: pointer; transition: 0.3s ease-in-out; } .submitButton:hover, .submitButton:focus { background-color: #6573c3; } .container { border: 1px solid #ccc; border-radius: 5px; box-shadow: 0 5px 15px 0px rgba(0, 0, 0, 0.2); background-color: transparent; padding: 20px; } .header { padding-bottom: 40px; } .alert { text-align: center; }

And finally, replace the content in pages/index.tsx so it displays the contact form:

pages/index.tsx

import ContactForm from "@/components/contact-form"; import styles from "@/styles/Home.module.css"; import Head from "next/head"; export default function Home() { return ( <> <Head> <title>Contact form demo</title> <meta name="description" content="Next.js contact form demo" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className={styles.main}> <ContactForm /> </main> </> ); }

At this point, you should be able to run your app:

npm run dev

Once the process completes, you can see your form component in action via http://localhost:3000.

3. Create API endpoint

When using the Page Router; the pages/api directory is mapped to /api/*. Files in this directory are treated as API routes instead of React pages. Taking that into account, inside the pages/api directory, create contact.ts. This file contains the code of the API route /api/contact that handles incoming requests to send messages. In this case, from the contact form.

pages/api/contact.ts

import { NextApiRequest, NextApiResponse } from "next"; import nodemailer from "nodemailer"; const EMAIL_REGEX = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/; function isEmpty(inputString?: string) { return !inputString || inputString.trim().length === 0; } export default async function handler( req: NextApiRequest, res: NextApiResponse ) { try { const { method, body } = req; if (method === "POST") { // Send email. await sendMail(body); res.status(200).send({ message: "Message successfully sent." }); } else { // Error if method is not correct. res.status(405).end(`Method ${method} not allowed`); } } catch (err) { // Catch errors thrown in the process. res.status(400).json({ error: err || "There was an error sending the message. Please try again later.", }); } } async function sendMail(data: { name: string; email: string; message: string; }) { const { name, email, message } = data; await new Promise((resolve, reject) => { if (!isEmpty(name) && !isEmpty(message) && !isEmpty(email)) { if (email && EMAIL_REGEX.test(email)) { // Setup email. const mailOptions = { from: process.env.OUTBOX_EMAIL, to: process.env.INBOX_EMAIL, subject: `Message From ${name} - ${email}`, text: message + " | Reply to: " + email, html: `<div>${message}</div>`, }; // Setup service. const transporter = nodemailer.createTransport({ host: "smtp.zoho.com", secure: true, port: 465, authMethod: "LOGIN", auth: { user: process.env.OUTBOX_EMAIL, pass: process.env.OUTBOX_EMAIL_PASSWORD, }, }); // Send email. transporter.sendMail(mailOptions, (err, response) => { if (err) { console.log(err); reject(err); } else { console.log("Email sent", response); resolve(response); } }); } else { reject("Please provide a valid email address."); } } else { reject("All fields are required."); } }); }

In the handler, we check for the correct request method. In this case, we check for POST. If the check passes, we call sendEmail. This function takes the contact form input values and performs basic input validations so we avoid sending emails with invalid content. After validating fields, we set up nodemailer for sending the message (transport). It then sends an email with the provided data. The email options, such as sender, recipient, subject, and email body, are defined in the mailOptions object.

Note that this code relies on environment variables for sensitive information like email addresses and passwords. These are not hard-coded for security reasons. Instead, we store them in the environment where the code runs; this is a common practice for securely handling sensitive information.

Get SMTP host credentials

The transporter configuration used in contact.ts works with a free Zoho Mail account, but you can tweak it depending on the service you want to use.

In the case of Zoho (and many other email clients), if your account has 2FA enabled, you'll need to generate an app password for this particular application, as your regular account password won't work. To create an app password in Zoho, follow these steps:

  1. Go to your Zoho account dashboard.
  2. After logging in, click on Security, then App Passwords.
  3. In the Application-Specific Passwords section, click on Generate New Password.
  4. In the modal that appears, type Nodemailer or any name that would make it easy to identify what password it is for.
  5. And finally, click Generate.

You will get an alphanumeric password, which you must save as it'll be used in our .env file alongside your email address to let the transporter connect for sending the message. These steps should be similar depending on the email client you want to use.

Create .env file

After getting our SMTP host credentials, we create a .env file at the root of the project:

.env

# .env file sample. # TODO: Modify values with your actual credentials. # Messages sent from the contact form will be sent from this address. OUTBOX_EMAIL=youroutboxemail@address.com OUTBOX_EMAIL_PASSWORD=SaMpl3Password # Email address where we want to receive the messages sent from the contact form. It can be the same as your OUTBOX_EMAIL. INBOX_EMAIL=yourinboxemail@address.com

Important: As we're storing sensitive information in the .env file, this file should not be committed to any public repo.

4. Wire up the contact form component

The last step for making the form functional is to piece together our contact form component. At the root of the project, let's create a service folder.

mkdir service

Inside the service folder, let's create contact.ts. This file will be in charge of sending requests to the API for sending a message submitted from the contact form:

service/contact.ts

const API_ENDPOINT = "/api/contact"; export default async function handleSendEmail(data: { name: string; email: string; message: string; }): Promise<{ data?: unknown; message?: string; error?: string }> { const response = await fetch(API_ENDPOINT, { method: "POST", headers: { Accept: "application/json, text/plain, */*", "Content-Type": "application/json", }, body: JSON.stringify(data), }); return response.json(); }

handleSendEmail is an asynchronous function that takes in the data passed from the contact form fields and returns a Promise, which will contain the actual response of the request. A POST request is sent to /api/contact with the data serialized as a JSON string in the request body. The Accept and Content-Type headers are defined to handle JSON data. After sending the request, it waits for the response, parses the response body as JSON, and returns the resulting object.

Now we've everything we need in place. Let's go back to our contact form component components/contact-form.tsx and make the necessary modifications to make it fully functional. First, we're going to need to import handleSendEmail. Add the following at the top of your component code:

import handleSendEmail from "@/service/contact";

Next, we call handleSendEmail when the form is submitted. Let's replace // TODO: Send email. with:

// Clear response. setResponseMessage(undefined); const data = { name: eventData.get("name")?.toString(), email: eventData.get("email")?.toString(), message: eventData.get("message")?.toString(), }; const { name, email, message } = data; if (name && email && message) { const response = await handleSendEmail({ name, email, message }); if (response.error) { setResponseMessage(response.error); } else { setResponseMessage(response.message); } } else { setResponseMessage("All fields are required."); }

To show the submission response in the UI, we can save the response message/error in the component state. Let's replace // TODO: Save the response message in the component state. with:

const [responseMessage, setResponseMessage] = useState<string>();

And let's display the message at the bottom of the form. After <\form> add:

{ responseMessage && <p className="alert">{responseMessage}</p>; }

After those modifications, components/contact-form.tsx should look like this:

components/contact-form.tsx

import handleSendEmail from "@/service/contact"; import { FormEvent, useState } from "react"; import contactFormStyles from "./contact-form.module.css"; export default function ContactForm() { const [responseMessage, setResponseMessage] = useState<string>(); async function onSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const eventData = new FormData(event.currentTarget); // Clear response. setResponseMessage(undefined); const data = { name: eventData.get("name")?.toString(), email: eventData.get("email")?.toString(), message: eventData.get("message")?.toString(), }; const { name, email, message } = data; if (name && email && message) { const response = await handleSendEmail({ name, email, message }); if (response.error) { setResponseMessage(response.error); } else { setResponseMessage(response.message); } } else { setResponseMessage("All fields are required."); } } return ( <> <div className={contactFormStyles.container}> <h1 className={contactFormStyles.header}>Contact Me!</h1> <form onSubmit={onSubmit}> <label htmlFor="name">Name</label> <input type="text" title="Name" name="name" id="name" required className={contactFormStyles.formField} /> <label htmlFor="email">Email</label> <input type="email" title="Email" name="email" id="email" required className={contactFormStyles.formField} /> <label htmlFor="message">Message</label> <textarea rows={10} title="Message" name="message" id="message" required className={contactFormStyles.formField} ></textarea> <input type="submit" title="Send" value="Send" className={contactFormStyles.submitButton} /> </form> {responseMessage && ( <p className={contactFormStyles.alert}>{responseMessage}</p> )} </div> </> ); }

Congratulations! You have created a basic and fully functional contact form for your Next.js app. If you haven't already done so, give it a try by running npm run dev in your terminal.

The endpoint we created is set up so every nodemailer connection response gets logged in the console. If there's any problem with the connection (most likely due to a configuration issue or incorrect login credentials), it'll be displayed in your terminal.

You can find the source code of this guide in my GitHub repository.

Next steps

Security-wise, this form is far from done. To make your form secure and avoid spam, you must consider the following:

Better validations

The validations used in this guide for both the back end and front end are very basic. It is necessary to tweak them accordingly to make a more secure form and avoid unnecessary API calls.

On the client side, we're using built-in HTML form validations, which meet our purpose here as this is a simple form. But this approach isn't recommended for an application in production; for managing complex forms and handling front-end validations, I highly recommend using react-hook-form. If you aren't familiar with the library, you can check it out here. I'm pretty sure it'll make your life easier in future projects.

CAPTCHA integration

Chances are, that you've encountered this while using some of your favorite websites, but if you aren't familiar with the term, CAPTCHA stands for Completely Automated Public Turing test to tell Computers and Humans Apart. It's a security measure used in web forms to distinguish between human users and automated bots.

If your contact form will be available to the public, it's necessary to set up a CAPTCHA method for your form to prevent spam.

Important: While CAPTCHA can significantly improve the security of your form, it’s not a silver bullet. It should be used as part of a comprehensive security strategy. Also, it’s important to strike a balance between security and user experience. Some CAPTCHA systems can be difficult for users to solve, which might lead to frustration and abandonment of the form they're trying to complete. Because of that, it’s essential to choose a CAPTCHA system that provides adequate security and is also user-friendly.

If you want to learn how to integrate CAPTCHA with the form we've just created, check out this guide.

Update (): Added link to the guide for integrating forms with CAPTCHA.

Share:
Questions or comments?

Was this helpful?

If this content added value to your day, consider fueling my late-night coding sessions! Your support helps me keep creating and sharing more geeky goodness.

Buy me a coffeeBuy me a coffee