All posts

Create a contact form using Next.js server actions

8 min read

One powerful feature that often comes into play, in order to enhance user engagement, is the creation of a dynamic and efficient contact form. In this blog post, we will learn how to craft a robust contact form using Next.js new server actions.

What are server actions ?

Server actions are asynchronous server-side functions that you can call from both server and client components. Allowing developers to handle form submissions and data mutations without the need to create separate API routes for that matter.

For server components, you can inline a server action inside the component using the 'use server' directive at the top of the function definition. Alternatively, you can create a new file with 'use server' directive at the top above any imports, this way all exported functions from that file will be marked as server actions available for reuse.

For client components, you can only import actions from a file that uses the directive, as inlining won't work in this case.

Creating the contact form

In order to create our form, we are going to launch a Next.js (I'm using version 14) project with typescript. For other configurations, defaults are fine.

npx create-next-app@latest --typescript

We also need to install some dependencies that we are going to use later.

npm install nodemailer zod
npm install --save @types/nodemailer

Then, we are going to create a simple form that has three fields: name, email, and a message.

// app/page.tsx
export default function Home() {
  return (
    <main>
      <h1>Contact</h1>
      <form>
        <input type="text" name="name" />
        <input type="email" name="email" />
        <textarea name="message" id="message"></textarea>
        <button type="submit">Send message</button>
      </form>
    </main>
  );
}

When a user submits a contact request, we want our server to process the data and then send and email with the message to whatever mailbox you or your business have. Here, goes our server action with the help of nodemailer which is a library for sending emails from a Node.js environment.

So we create a new folder actions and inside it we create a new file send-email.ts where first, we are going to create a transporter with the configuration needed for nodemailer to be able to send emails through SMTP. You can use any known service provider to get these configurations (e.g Sendgrid)

We save those credentials to our .env.local file and retrieve them in our server action file.

'use server';

import nodemailer from 'nodemailer';

const transport = nodemailer.createTransport({
  host: process.env.NEXT_TRANSPORT_HOST,
  port: Number(process.env.NEXT_TRANSPORT_PORT) || 578,
  auth: {
    user: process.env.NEXT_TRANSPORT_USERNAME,
    pass: process.env.NEXT_TRANSPORT_PASSWORD,
  },
});

We can now define our action for sending the email, this function is getting a FormData object with all the data submitted from our form, and delivering an email using the sendEmail() method from the previously created transport.

export async function sendEmail(formData: FormData) {
  const name = formData.get('name');
  const email = formData.get('email');
  const message = formData.get('message');

  try {
    await transport.sendMail({
      to: 'your-receiving-email@example.com',
      from: 'your-email-from-service-provider@example.com',
      subject: 'New contact request!',
      html: `<!DOCTYPE html>
        <html lang="en">
        <body>
            <p>Name: ${name}</p>
            <p>Email: ${email}</p>
            <p>Message: ${message}</p>
        </body>
        </html>`,
    });
    return { sent: true };
  } catch (error) {
    return { sent: false };
  }
}

Then, we can import this action in our component and use it in the form action attribute:

// app/page.tsx
import { sendEmail } from "./actions/send-email";

export default function Home() {
  return (
    // ...
      <form action={sendEmail} >

      // ...
  );
}

Input validation

To validate our form input, we can use basic HTML validation for the client-side, here we can add the required attribute to every input and also the email type for the email input.

As our server is going to process the input sent directly from the client, we have to validate and escape this input server-side as well. For this purpose, we are going to use zod, which is a schema declaration and validation library, to validate form fields before sending the email.

First, we have to create a schema object with the needed fields, all fields shouldn't be empty, with a maximum number of characters for both name and message, and a valid email address, we can also add error messages in case these rules are violated.

// app/actions/send-email.ts
const schema = z.object({
  name: z
    .string()
    .max(100, { message: 'Name should be less than 100 characters' })
    .min(1, { message: 'Name is required' }),
  email: z.string().email({
    message: 'Invalid email address',
  }),
  message: z
    .string()
    .max(500, { message: 'Message should be less than 500 characters' })
    .min(1, { message: 'Message is required' }),
});

Then, we have to update our action, just before sending the email we have to safely parse our input using safeParse method on the previously created schema. If there are any errors, we are going to include them in the returned response object.

export async function sendEmail(formData: FormData) {
  // ...
  const validatedFields = schema.safeParse({
    name,
    email,
    message,
  });

  if (!validatedFields.success) {
    return {
      sent: false,
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }

  try {
    // ...
  }
}

Back to our contact form, we would normally want to show error messages for each field in case they occur, for that we are going to use the useFormState React hook, and this will introduce two changes:

  • The signature of our action's function, as it will now receive a new parameter as its first argument: the initialState, which is basically the initial value of the state before invoking the action.
  • Our contact component must now be a client component since we are using a React hook.

So, let's start with our action by adding the state as a first argument

export async function sendEmail(initialState: any, formData: FormData) {
  //...
}

Then, let's add the hook to our contact component, we pass it our sendEmail action and the initialState object, notice that now we are going to use the action sendAction returned from the hook. Also, we are going to add error messages related to each field in the form.

'use client';

import { useFormState } from 'react-dom';
import { sendEmail } from './actions/send-email';

const initialState = {
  sent: false,
  errors: {},
};

export default function Home() {
  const [state, sendAction] = useFormState(sendEmail, initialState);
  return (
    <main>
      <h1>Contact</h1>
      <form action={sendAction}>
        <input type="text" name="name" required />
        {state.errors?.name && <span>{state.errors.name[0]}</span>}
        <input type="email" name="email" required />
        {state.errors?.email && <span>{state.errors.email[0]}</span>}
        <textarea name="message" id="message" required></textarea>
        {state.errors?.message && <span>{state.errors.message[0]}</span>}
        <button type="submit">Send message</button>
      </form>
    </main>
  );
}

Pending state

One last thing we are going to add is a loading state to the submit button while our contact form is being submitted, this way users get a hint that their message is being sent. Also, a message indicating the success of the operation in the end is very useful.

Here, we can use another React hook useFormStatus which will give us a boolean indicating the pending state. For that to work, we need to first, make the submit button a separate client component while keeping it a child of our form element.

// app/submit-button.tsx
import { useFormStatus } from 'react-dom';

export default function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Sending...' : 'Send message'}
    </button>
  );
}

Finally, we update our form with the button and the success message.


// ...
import SubmitButton from "./submit-button";

//...

export default function Home() {
  // ...
    <main>
      <h1>Contact</h1>
      <form action={sendAction}>
        // ...
        <SubmitButton />
        {state.sent && <span>Message sent!</span>}
      </form>
    </main>
  );
}

You can find the full demo app in GitHub