Next.jsAWSTypeScriptSESIAM | 15 Min Read

How to Build a Contact Form With Next.js and AWS

It's pretty common to have a contact form on your website but building them can be a pain. So, let's explore how to easily build one using Next.js and AWS!

It’s pretty common nowadays to have a contact form on your website to make it a super simple process for people to contact you. And, while the functionality is quite simple in nature, actually building a contact form solution can be pretty involved depending on how little or how much you want to hand off to 3rd party packages and products.

However, not everyone wants to use 3rd party packages or solutions, whether that be because you want to learn something new or you want to fully control the process and code end to end. So, if you fall into the camp of wanting to control the process or are just curious about building with AWS, then this post is for you.

By the end of this post, we’re going to have a fully working contact form in Next.js that can send us emails quickly and cheaply using various AWS services.

Our Tech Stack

Before jumping into the tutorial, let’s cover the tech stack we’ll be using for this tutorial. Of course, as mentioned, we’ll be using Next.js and more specifically we’ll be making good use of the API routes feature. From an AWS perspective, we’ll be using a few services, which are SES, Lambda, API Gateway, and IAM.

📣 NOTE: For this tutorial, I’m going to be using the AWS Web UI to configure the various services listed but if you’re interested in seeing a version of this post using the AWS CDK make sure to let me know.

Configuring AWS

We’re going to start by configuring AWS, to get started, head over to AWS and sign in with your account if you already have one you want to use for this tutorial. Otherwise, create a new account.

As a quick note, if you’re going to create a new account for this tutorial, then make sure you create a new IAM user first before continuing with this tutorial as it’s bad practice to use the root user for provisioning new resources as detailed here.

After your account is ready to go and is all configured, choose the region you’re going to be deploying your resources to in the top right. For me, this will be `eu-west-2` as that’s my closest region available.

SES

We’ll be configuring SES first, this is the service that will handle the sending of emails from our contact form to our specified email address. So, to get started, head over to SES by typing it into the search box in the top left of the AWS dashboard and clicking on “Amazon Simple Email Service”. Once, on the SES dashboard, click on “verified identities” on the left-hand sidebar and then click on “Create new identity” to add a new email address.

Then, click on “email address” and finally enter the email address you want the emails to be sent to and then click on “Create Identity”. You’ll now be sent an email to the email address that you need to open and click on a link to verify you own/have access to the email address. Once the email is verified, you’ll be able to send email to it using SES.

IAM

Our next stop is IAM so using the search box again, head over to the IAM dashboard and click on “Policies” on the left sidebar followed by “Create Policy”, switch to the JSON editor, and paste in the below JSON object.

1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "VisualEditor",
6 "Effect": "Allow",
7 "Action": ["ses:SendEmail", "ses:SendRawEmail"],
8 "Resource": "*"
9 }
10 ]
11}
json

With that JSON pasted in, push the new “next” button twice, and then on the “review” page name your new policy `SendContactEmailPolicy` and click on “Create Policy” to finish creating it.

Now, let’s use our new policy in a role that will allow a Lambda function to send emails using SES. To get started creating this role, go to “Roles” on the left sidebar, followed by “Create Role” and then choose “AWS Service” and “Lambda” from the options provided. Then click on “Next” and then on the “Permissions” screen, choose the policy we just created before finally clicking “next” again and then naming the new role `SendContactEmail` and clicking on “Create Role”.

That’s it, we’re now finished with IAM and have created the role our Lambda function will use in a moment to be allowed to access SES to send emails for us. Let’s move on to configuring Lambda next.

Lambda

Much like we did for SES and IAM, enter Lambda in your search box and click on its name to go to the Lambda dashboard. From there click on “Create function”, then “Author from scratch” and give it a name, I’ll be calling mine `sendContactEmail`, then finish creating your new function by clicking on “Create function”, leaving all the defaults in place.

Then open up your new Lambda function from the dashboard if it’s not already open and then select your `index.mjs` file in the code editor and paste into it the below code. You’ll need to make some changes in the code before we can deploy it though, these are.

  • Replace `YOUR_VERIFIED_EMAIL` with the email address you configured in SES earlier.
  • Replace `AWS_REGION` with the region you selected at the start.

With these changes made, you’re ready to deploy so press the “Deploy” button to save and make your Lambda ready to use.

index.mjs
1const aws = require("aws-sdk");
2const ses = new aws.SES({ region: "AWS_REGION" });
3
4exports.handler = async function (event) {
5 // Get data from the request sent from the frontend that triggered the lambda
6 const { firstName, lastName, email, message } = JSON.parse(event.body);
7
8 // Config for SES to send the email
9 const params = {
10 // Email address the email is sent to
11 Destination: {
12 ToAddresses: ["YOUR_VERIFIED_EMAIL"],
13 },
14 Message: {
15 // Body of the email
16 Body: {
17 Text: {
18 Data: `
19New message:
20---
21Name:${firstName} ${lastName}
22Email: ${email}
23Message: ${message}
24`,
25 },
26 },
27 // Subject line of the email
28 Subject: { Data: `Contact Form Message` },
29 },
30 // Email address the email is sent from
31 Source: "YOUR_VERIFIED_EMAIL",
32 };
33
34 // Send the email
35 return ses.sendEmail(params).promise();
36};
js

With our Lambda code configured and ready to go, we just need to configure a trigger for it using API Gateway. We can do a lot of this on our Lambda’s configuration page so to get started, select the “configuration” tab and then the “Triggers” sub-menu, then click on “Add Trigger”, then select “API Gateway” from the dropdown and click on “Create a new API”, followed by “REST API” and set the security equal to “API Key”. Then finish adding the new trigger by clicking “Add”.

Also, while we’re configuring the Lambda, let’s add in our IAM role from earlier to give it permission to send emails using SES. To do this, click on “Permissions” on the left-hand side of the “Configuration” page, then edit the “Execution Role” and select the role you created earlier from the “Use an existing role” dropdown. (You may need to hit the refresh button next to the dropdown if no items show up at first). Then press “save” to confirm those changes.

To finish off our Lambda/API Gateway configuration we need to obtain our REST API endpoint and API Keys to use with it for authentication. To do this, click on “Triggers” again and then copy your “API Endpoint”, keep this value safe as we’ll need it in a moment when we get into Next.js. Then under “Triggers” still, click on the name of your API to open the API Gateway dashboard, from their click on “API Keys” on the left, and then click on the name of the API Key based on your Lambda name from earlier. Finally, click on the “Show” option on the API key and copy this value as well.

And, that’s it! All of the AWS work is now complete and we’re ready to move into the frontend portion of this tutorial to hook up the contact form and get it working!

Configuring Next.js

If you have an existing Next.js repository you’d like to use with this tutorial you can but I’ll be creating a new one using `npx create-next-app@latest --ts`. Once you’ve created the project, `cd` into it and open the project in your favorite code editor. Throughout the frontend, I’ll be using TailwindCSS for styling, if you wish to use this as well, you can follow their great install guide. Otherwise, feel free to switch out the TailwindCSS styles for something else.

📣 NOTE: I’ll be using TypeScript in the Next.js project, if you’re not using TypeScript, you’ll need to edit the code to remove the TypeScript code and convert it to standard JavaScript.

The first thing we need to create is a `.env.local` at the root of the repository if it does not already exist. Then, inside this file, we need to add our endpoint and API Key from earlier like so.

1CONTACT_FORM_ENDPOINT = "YOUR_API_ENDPOINT_HERE";
2CONTACT_FORM_API_KEY = "YOUR_API_KEY_HERE";

If you’re using TypeScript, you may also wish to add an `additional.d.ts` file in the root of your project with the following contents.

additional.d.ts
1declare global {
2 namespace NodeJS {
3 interface ProcessEnv {
4 CONTACT_FORM_ENDPOINT: string;
5 CONTACT_FORM_API_KEY: string;
6 }
7 }
8}
9
10export {};
ts

This file just ensures that TS knows that the envs we’ve added are of the `string` type so won’t cause any linting errors with `any` or `unknown` types being used. To finish this setup, we just need to edit our `tsconfig.json` file. Inside your `tsconfig.json` add `"additional.d.ts"` to the `includes` array so it looks like the below.

tsconfig.json
1{
2 "compilerOptions": {
3 ...compiler settings
4 },
5 "include": [...other files, "additional.d.ts"],
6 "exclude": [...files]
7}
json

After, configuring our env files, let’s move on to configuring our API route that will handle the actual submission of the data to the API Gateway endpoint we configured. So, create a new file at `./pages/api/contactForm.ts` and paste into it the below code. We will also need to install `isomorphic-fetch` to allow us to use fetch in a Node.js environment so install that using `npm i isomorphic-fetch`.

./pages/api/contactForm.ts
1import "isomorphic-fetch";
2import type { NextApiRequest, NextApiResponse } from "next";
3import { ContactFormValues } from "../../types";
4
5interface ExtendedNextApiRequest extends NextApiRequest {
6 body: ContactFormValues;
7}
8
9interface ExtendedNextApiResponse extends NextApiResponse {
10 message: string;
11}
12
13type TBodyFields = {
14 [key: string]: string,
15};
16
17export default async function contactForm(
18 req: ExtendedNextApiRequest,
19 res: ExtendedNextApiResponse
20) {
21 const { body }: { body: TBodyFields } = req;
22
23 // Checking we have data from the email input
24 const requiredFields = ["email", "firstName", "lastName", "message"];
25
26 for (const field of requiredFields) {
27 if (!body[field]) {
28 res.status(400).json({
29 message: `Oops! You are missing the ${field} field, please fill it in and retry.`,
30 });
31 }
32 }
33
34 // Setting vars for posting to API
35 const endpoint = process.env.CONTACT_FORM_ENDPOINT;
36
37 // posting to the API
38 await fetch(endpoint, {
39 method: "post",
40 body: JSON.stringify({
41 firstName: body.firstName,
42 lastName: body.lastName,
43 email: body.email,
44 message: body.message,
45 }),
46 headers: {
47 "Content-Type": "application/json",
48 "x-api-key": process.env.CONTACT_FORM_API_KEY,
49 charset: "utf-8",
50 },
51 });
52
53 res.status(200).json({ message: "Success! Thank you for message!" });
54}
ts

In this function, we take in the `body` of the request, ensure all the fields we’ve specified as required are populated and then we send a `POST` request off to the API Gateway Endpoint we created. We also do some basic error handling so that if any required fields are not populated, we return an error to the user to let them know.

At this point, if you’re using TS, you will have some type errors for the missing `ContactFormValues` so let’s sort those out by creating a `types.ts` file at the root of the project and adding in the below type to resolve our type issue in the API route.

types.ts
1export type ContactFormValues = {
2 email: string,
3 firstName: string,
4 lastName: string,
5 message: string,
6};
ts

Now, we need to create a couple of custom React hooks to support the `ContactForm` component we’re going to be creating in a moment. The first one of these is called `useContactForm` and will handle the submission of the form to the Next.js API route so create a new file at `./hooks/useContactForm.ts` and paste in the below code.

./hooks/useContactForm.ts
1import { SyntheticEvent, useState } from 'react';
2import { server } from '../config';
3import { ContactFormValues } from '../types';
4
5interface IProps {
6 values: ContactFormValues;
7 resetValues: () => void;
8}
9
10type Output = {
11 message: string;
12};
13
14export default function useContactForm({ values, resetValues }: IProps) {
15 // Setting state to be returned depending on the outcome of the submission.
16 const [loading, setLoading] = useState<boolean>(false);
17 const [outputMessage, setOutputMessage] = useState<string | null>('');
18 const [error, setError] = useState<boolean | null>();
19
20 // destructuring out the values from values passed to this form.
21 const { firstName, lastName, email, message } = values;
22
23 async function submitContactForm(e: SyntheticEvent) {
24 // Prevent default function of the form submit and set state to defaults for each new submit.
25 e.preventDefault();
26
27 // Set base state
28 setLoading(true);
29 setError(null);
30 setOutputMessage(null);
31
32 // gathering data to be submitted to the serverless function
33 const body = {
34 firstName,
35 lastName,
36 email,
37 message,
38 };
39
40 const requiredFields = ['email', 'firstName', 'lastName', 'message'];
41
42 // Checking required fields aren't empty.
43 for (const field of requiredFields) {
44 if (!field?.length) {
45 setLoading(false);
46 setError(true);
47 setOutputMessage(
48 `Oops! The field: ${field} is empty, please fill it in and retry.`
49 );
50 return;
51 }
52 }
53
54 // Send the data to the serverless function on submit.
55 const res = await fetch(`${server}/api/contactForm`, {
56 method: 'POST',
57 headers: {
58 'Content-Type': 'application/json',
59 },
60 body: JSON.stringify(body),
61 });
62
63 const responseText: string = await res.text();
64
65 // Waiting for the output of the serverless function and storing into the serverlessBaseoutput var.
66 const output = (await JSON.parse(responseText)) as Output;
67
68 // check if successful or if was an error
69 if (res.status >= 400 && res.status < 600) {
70 // Oh no there was an error! Set to state to show user
71 setLoading(false);
72 setError(true);
73 setOutputMessage(output.message);
74 } else {
75 // everyting worked successfully.
76 setLoading(false);
77 setOutputMessage(output.message);
78 resetValues();
79 }
80 }
81
82 return {
83 error,
84 loading,
85 outputMessage,
86 submitContactForm,
87 };
88}
ts

There’s a fair amount going in on this custom hook but essentially it handles the submission of the form to the API route and then handles the response from the API route as well as error and loading states that we can use in the UI. It also validates the required fields as well.

The next custom hook we need to create is the `useForm` hook which will handle the storing and updating of values used in the form prior to its submission. This means when we submit the form, we can take the values stored in the state of this hook as the values for the request to the API. So, to create this hook, create a new file at `./hooks/useForm.ts` and paste into it the below code.

./hooks/useForm.ts
1import { useState } from 'react';
2import { ContactFormValues} from '../types';
3
4type UpdateProps = {
5 target: {
6 value: string;
7 name: string;
8 };
9};
10
11export type UseFormUpdateValues = ({
12 target: { value, name },
13}: UpdateProps) => void;
14
15interface ReturnProps {
16 values: ContactFormValues;
17 updateValue: UseFormUpdateValues;
18 resetValues: () => void;
19}
20
21export default function useForm(
22 defaults: ContactFormValues
23): ReturnProps {
24 const [values, setValues] = useState(defaults);
25
26 function updateValue({ target: { value, name } }: UpdateProps) {
27 // Set the value by spreading in the existing values and chaging the key to the new value or adding it if not previously present.
28 setValues({
29 ...values,
30 [name]: value,
31 });
32 }
33
34 function resetValues() {
35 setValues(defaults);
36 }
37
38 return { values, updateValue, resetValues };
39}
ts

Finally, we just need to create our `ContactForm` component to consume the custom hooks we’ve created above. So, add a new file at `./components/ContactForm.tsx` with the below code.

./components/ContactForm.tsx
1import React from "react";
2import useContactForm from "../hooks/useContactForm";
3import useForm from "../hooks/useForm";
4
5export default function ContactForm(): JSX.Element {
6 const { values, updateValue, resetValues } = useForm({
7 firstName: "",
8 lastName: "",
9 email: "",
10 message: "",
11 });
12
13 const { firstName, lastName, email, message } = values;
14
15 const { loading, outputMessage, submitContactForm } = useContactForm({
16 values,
17 resetValues,
18 });
19
20 const inputContainerStyles = "flex flex-col items-start ";
21 const labelStyles = "font-bold mb-1 ";
22
23 return (
24 <div className="flex flex-col justify-start items-center p-6 rounded-md col-span-5 xl:col-span-2">
25 <form
26 onSubmit={submitContactForm}
27 className="flex flex-col gap-4 md:gap-6 w-full"
28 data-testid="contact-form"
29 >
30 <div className="grid grid-cols-1 sm:grid-cols-2 w-full gap-4">
31 <div className={inputContainerStyles}>
32 <label htmlFor="firstName" className={labelStyles}>
33 First Name
34 </label>
35 <input
36 type="text"
37 name="firstName"
38 id="firstName"
39 required
40 placeholder="Your first name"
41 onChange={updateValue}
42 value={firstName}
43 className="rounded-md text-md w-full"
44 />
45 </div>
46 <div className={inputContainerStyles}>
47 <label htmlFor="lastName" className={labelStyles}>
48 Last Name
49 </label>
50 <input
51 type="text"
52 name="lastName"
53 id="lastName"
54 required
55 placeholder="Your last name"
56 onChange={updateValue}
57 value={lastName}
58 className="rounded-md text-md w-full"
59 />
60 </div>
61 </div>
62 <div className={inputContainerStyles}>
63 <label htmlFor="email" className={labelStyles}>
64 Email Address
65 </label>
66 <input
67 type="email"
68 name="email"
69 id="email"
70 required
71 placeholder="Your email"
72 onChange={updateValue}
73 value={email}
74 className="rounded-md text-md w-full"
75 />
76 </div>
77 <div className={`grow ${inputContainerStyles}`}>
78 <label htmlFor="message" className={labelStyles}>
79 Message
80 </label>
81 <textarea
82 name="message"
83 id="message"
84 required
85 placeholder="Your message"
86 onChange={updateValue}
87 value={message}
88 className="rounded-md text-md w-full resize-none h-full max-h-[150px]"
89 />
90 </div>
91 <button
92 type="submit"
93 className="bg-blue-600 text-white text-base font-bold rounded-md py-3 px-5"
94 >
95 {loading ? "Sending.." : "Send Message"}
96 </button>
97 </form>
98 <p
99 className={`text-md lg:text-base mt-6 ${
100 !outputMessage ? "animate-pulse" : ""
101 }`}
102 >
103 {outputMessage || "Awaiting Submission..."}
104 </p>
105 </div>
106 );
107}
tsx

And, that’s it, we’ve just created the contact form component and completed all of the work required for the frontend to send a request to the API in AWS to send us an email with the contents of the contact form. Let’s now test it by adding our contact form to our home page so in your `./pages/index.ts` file, import your new `ContactForm` component and add it to the page. Then, start up your development server using `npm run dev` and fill in the contact form and press submit, if all went to plan, you should have an email drop in your inbox in a few moments with the details you just entered!

Closing Thoughts

If everything went to plan, you should now have a fully functional contact form using AWS and Next.js. So just to recap, in this post, we used the AWS web interface to configure multiple AWS services to work together with our Next.js frontend to handle the submission of a contact form and have it automatically sent to our configured email address.

If you’re interested in seeing the completed code for this project, you can see the Next.js code on my GitHub here.

And, as a closing note, if you followed along with this tutorial for a learning experience and don’t plan on actually using the services configured, make sure to go back through the tutorial and delete/de-provision everything that was configured in AWS so you don’t get any unexpected bill.

Finally, I hope you found this post interesting, enjoyable, and helpful and if you’re interested in working with me, I’d love to hear from you and how I can help you and/or your business.

Thank you for reading.



Content

Latest Blog Posts

Below is my latest blog post and a link to all of my posts.

View All Posts

Content

Latest Video

Here is my YouTube Channel and latest video for your enjoyment.

View All Videos
Get more clients by this doing 1️⃣ thing!

Get more clients by this doing 1️⃣ thing!

Contact

Join My Newsletter

Subscribe to my weekly newsletter by filling in the form.

Get my latest content every week and 0 spam!