Creating a standalone contact form with Next.js

I recently built a custom contact form for my personal website for allowing visitors to send me emails. While this sounds pretty basic, I had a few constraints that made the solution a bit more complex.

Goals

First up, I wanted the solution to be standalone i.e. not using a third-party service such as Mailchimp, Formspree or anything else to handle sending the email. This basically meant it had to be handled in Node.js using a Next API route and I'd need to find a reliable way of sending emails using Node.js (more trouble than it initially sounds).

Another feature I wanted is file uploading, both through a button and through a dropzone (drag and drop files onto the page). But I wanted it to be good™️, by informing the user BEFORE submission if the files were too big or the formats were unsupported.

Lastly, it would be super nice to have toasts appear on error and success states.

Let's get started!

The Front-End

Naturally since this is part of a modern Next.js site, the entire front-end is written in Typescript. A couple of libraries we'll need to start:

  • react-hot-toast: a properly good solution for simple toasts. Lightweight, gorgeous and promise-based, what more could you ask for?
  • react-use: a collection of essential React Hooks. Specifically, we'll be using useDrop. This library is quickly becoming my favourite package on NPM.

Rather than posting a huge slab of code here, I'll go into the details around the specifics. If you want the full code for a solid copy-paste, you can find it right here.

First up: scaffolding - our imports and page function.

import type { NextPage } from 'next';
import { useRef, useState } from "react";
import type { ChangeEvent, FormEvent } from "react";
import toast, { Toaster } from 'react-hot-toast';
import { useDrop } from 'react-use';

const Contact: NextPage<IContact> = () => {
  return <div />;
};

export default Contact;

Couple of nice-to-haves here: Next.js exposes an interface called NextPage which we can use to add type definitions for our entire page. React does the same for Form Events and Input Change Events so we can properly type them.

Also, you may be wondering about import type. Microsoft sums it up pretty well:

import type only imports declarations to be used for type annotations and declarations. It always gets fully erased, so there's no remnant of it at runtime.

Also - I included my <Toaster /> in my layout.tsx so I could call toast() everywhere but I've added it here so it makes sense out of context.

Now: states hooks!

const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const fileInput = useRef<HTMLInputElement>(null);
const dropState = useDrop({
  onFiles: addFiles,
  onUri: () => toast.error('Files only please!'),
  onText: () => toast.error('Files only please!'),
});

Some things to note - I've explicitly set up files as a File[]. This opens the door to a couple of questions, mostly around how the DOM deals with "file lists"... we'll get onto this later. I like using the built-in File type though over a custom object as it's immediately and reliably typed.

Also, the dropState will be used for the dropzone obviously, but the handy thing here is that it supports handling uris and text, meaning I'm able to throw nice errors when folks try to upload things that aren't files.

Okay enough preamble. Let's write the "Send Email" function:

async function sendEmail(event: FormEvent) {
  event.preventDefault();

  setLoading(true);

  try {
    const formData = new FormData();

    if (!name.trim()) {
      throw new Error("Please provide a valid name.");
    }

    if (!email.trim()) {
      throw new Error("Please provide a valid email address.");
    }

    if (!message.trim()) {
      throw new Error("Please provide a valid message.");
    }

    formData.append("name", name);
    formData.append("email", email);
    formData.append("message", message);
    files.map((file, index) =>
      formData.append(`file${index}`, file)
    );

    const response = await fetch("/api/nodemailer", {
      method: "post",
      body: formData,
    });

    const responseData = await response.json();

    if (responseData.error) {
      throw new Error(responseData.error);
    }

    toast.success("Thanks, I’ll be in touch!");

    setName("");
    setEmail("");
    setMessage("");
    setFiles([]);
  } catch (error) {
    toast.error(error.message);
  } finally {
    setLoading(false);
  }
}

You may note I've decided to treat the entire request as a FormData submission. If you're unclear on that, MDN sums it up well:

The FormData interface provides a way to easily construct a set of key/value pairs representing form fields and their values, which can then be easily sent using the XMLHttpRequest.send() method. It uses the same format a form would use if the encoding type were set to "multipart/form-data".

This is because the library we'll be using to actually send the email works well with a multipart/form-data encoding type on the data. More on this later.

Now, let's write our JSX:

return (
  <>
    <form
      className={`${styles.form} ${loading ? styles.loading : ''}`}
      onSubmit={sendEmail}
    >
      <fieldset className={styles.fieldset}>
        <div className={styles.fieldHeader}>
          <label className={styles.label} htmlFor="name">
            Full name
          </label>
          <span className={styles.remaining}>{name.length} / 320</span>
        </div>
        <input
          className={styles.input}
          id="name"
          name="name"
          type="text"
          placeholder="Jane Smith"
          required
          autoComplete="on"
          value={name}
          maxLength={320}
          onChange={({ target }: ChangeEvent) =>
            setName((target as HTMLInputElement).value)
          }
        />
      </fieldset>
      <fieldset className={styles.fieldset}>
        <div className={styles.fieldHeader}>
          <label className={styles.label} htmlFor="email">
            Email address
          </label>
          <span className={styles.remaining}>{email.length} / 320</span>
        </div>
        <input
          className={styles.input}
          id="email"
          name="email"
          type="email"
          placeholder="[email protected]"
          required
          autoComplete="on"
          value={email}
          pattern=".+@.+\..+"
          maxLength={320}
          onChange={({ target }: ChangeEvent) =>
            setEmail((target as HTMLInputElement).value)
          }
        />
      </fieldset>
      <fieldset className={styles.fieldset}>
        <div className={styles.fieldHeader}>
          <label className={styles.label} htmlFor="message">
            Message
          </label>
          <span className={styles.remaining}>{message.length} / 1000</span>
        </div>
        <textarea
          className={styles.textarea}
          id="message"
          name="message"
          placeholder="What's on your mind?"
          required
          autoComplete="off"
          value={message}
          maxLength={1000}
          onChange={({ target }: ChangeEvent) =>
            setMessage((target as HTMLInputElement).value)
          }
        />
      </fieldset>
      <fieldset className={styles.fieldset}>
        <label className={styles.label} htmlFor="files">
          Upload files (Optional)
        </label>
        <input
          value={[]}
          ref={fileInput}
          hidden
          id="files"
          name="files"
          type="file"
          multiple
          onChange={onChangeFiles}
        />
        <button className={styles.files} onClick={clickFiles}>
          Upload files
        </button>
        <div className={styles.fileList}>
          {files.map((file, index) => (
            <div className={styles.file} key={file.name}>
              <span>{file.name} ({(file.size / 1024).toFixed(2)}kb)</span>
              <span className={styles.remove} onClick={() => removeFile(index)}>&times;</span>
            </div>
          ))}
        </div>
      </fieldset>

      <button className={styles.button} type="submit">
        Send me a message
      </button>
    </form>

    <div className={`${styles.dropzone} ${dropState.over ? styles.active : ''}`} />

    <Toaster toastOptions={{
      duration: 5000,
      position: 'bottom-right',
    }} />
  </>
);

Now, some things to note!

By default, HTML email pattern recognition sucks. It's way to relaxed, so addresses like a@a are totally fine. You can bump this up a notch by adding a simple pattern attribute like pattern=".+@.+\..+" to change the pattern to roughly [email protected] without needing any JS checks.

Also, you'll notice I've skipped over all the upload file related functions. They're a bit more complex, so I'll cover them now:

function addFiles(newFiles: File[]) {
  newFiles.forEach((file, index) => {
    const fileExists = files.some(({ name, size }) => name === file.name && size === file.size);

    if (fileExists) {
      toast.error(`You already uploaded ${file.name}`);
      newFiles.splice(index);
    }

    if (file.size > 5000000) {
      toast.error(`${file.name} is too chonky (5MB max file size).`);
      newFiles.splice(index);
    }

  });

  setFiles([...files, ...newFiles]);
}

function onChangeFiles({ target }: ChangeEvent<HTMLInputElement>) {
  if (target.files) {
    const newFiles = Array.from(target.files);

    addFiles(newFiles);
  }
}

function clickFiles() {
  fileInput.current?.click();
}

function removeFile(index: number) {
  const newFiles = files.filter((_, i) => i !== index);
  setFiles(newFiles);
}

Okay so your first question should be: in onChangeFiles, why are you turning what I assume is an array of files into an array of files?

Good question, but HTML's File Input Change Event actually gives us something called a FileList which is like an array, but more annoying because we can't map over it. Since we need a common type for "dropped" and "clicked" uploaded files, we're turning them into an array of files before updating the state.

Now, the next question: if you're that into using native interfaces, why not just use <input type="file" /> to handle your click-to-upload files?

Another great question! You're good at this. The reason is because the native HTML input lists the uploaded files next to the input button (or "No file chosen" if you haven't done it yet). As it turns out, we can't actually control the value of this input programatically for security reasons. So, it's easier just to implement our own custom interface.

The Back-End

Okay now we have the front-end out of the way, we can talk about actually sending the email! First up: dependencies.

We'll be using two key libraries for this setup:

  • nodemailer: a library to send emails from Node.js.
  • formidable: a Node.js module for parsing form data, especially file uploads. This library isn't typed or anything so I really need to find a replacement at some point.

Now, for the scaffolding.

import type { NextApiHandler } from "next";
import nodemailer from "nodemailer";
import formidable from "formidable";

const handler: NextApiHandler<APIResponse> = async(req, res) => {
  res.status(200).json({ message: 'It works... for now.' });
};

export default handler;

As usual, Next exposes a great little interface for us called NextApiHandler that types the entire function, including the request (req) and response (res) params. The best part though is you can give it a type parameter (which I've called APIResponse) that types your API's response object... how great is that!

Alternatively, you can use the NextApiRequest and NextApiResponse interfaces if you want to type the params manually.

Anyway, let's make some interfaces next:

type Fields = {
  name: string;
  message: string;
  email: string;
};

interface NodemailerFile extends File {
  path: string;
}

Fields is the interface we'll be using for the data that we receive in the FormData object (Formidable calls these "fields") and NodemailerFile exists because Nodemailer actually extends the native File type and adds a path property to the temporary location on disk.

Now, one more thing I want to do before getting into the weeds is promisif-y Formidable... because promises 💪

type FormidablePromise = {
  fields: Fields;
  files?: any;
};

function formidablePromise(req, opts): Promise<FormidablePromise> {
  return new Promise((resolve, reject) => {
    const form = new formidable.IncomingForm(opts);

    form.parse(req, (error: Error, fields: any, files: any) => {
      if (error) {
        return reject(error);
      }
      resolve({ fields, files });
    });
  });
}

Okay, now let's send an email!

const transporter = nodemailer.createTransport({
  service: "FastMail",
  auth: {
    user: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
    pass: process.env.NEXT_PUBLIC_EMAIL_PASSWORD,
  },
});

export const config = {
  api: {
    bodyParser: false,
  },
};

const handler: NextApiHandler<APIResponse> = async(req, res) => {
  if (req.method !== "POST") {
    return res.status(404).send({ error: "Begone." });
  }

  res.setHeader("Content-Type", "application/json");

  try {
    const { fields, files } = await formidablePromise(req, {});
    const fileArray: NodemailerFile[] = Object.values(files);
    const { name, email, message } = fields;

    if (!name || !name.trim()) {
      throw new Error("Please provide a valid name.");
    }

    if (!email || !email.trim()) {
      throw new Error("Please provide a valid email address.");
    }

    if (!message || !message.trim()) {
      throw new Error("Please provide a valid email message.");
    }

    await transporter.sendMail({
      to: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
      from: process.env.NEXT_PUBLIC_EMAIL_ADDRESS,
      replyTo: email,
      subject: `Hello from ${name}`,
      text: message,
      html: `<p>${message.replace(/(?:\r\n|\r|\n)/g, "<br>")}</p>`,
      attachments: fileArray.map(({ name, path, type }) => ({
        filename: name,
        path: path,
        contentType: type,
      })),
    });

    res.status(200).json({});
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};

There's plenty of things to cover here, but the main one I want to cover is nodemailer.createTransport.

As it turns out, sending emails from Node.js SUCKS and somehow, even PHP is better in this regard. To actually send an email here, we need to set up a "transporter" - basically a configuration for the SMTP layer. You can read more about this here. On top of this, I assume the majority of people will be using Gmail as their mail provider (I use FastMail because reasons) and that particular transporter comes with it's own set of problems.

Anyway, long story short you need to actually connect to your email provider to send emails, meaning you'll inevitably end up sending emails to yourself. In this sense, I recommend adding a replyTo field so you can immediately hit reply on the email in your inbox and be chatting to the right person.

That's it for today! Hopefully that wasn't too painful. If you want the full code to steal, you can copy and paste it from here. Otherwise, happy hacking!

19