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
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.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
}
]
}
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.
const aws = require("aws-sdk");
const ses = new aws.SES({ region: "AWS_REGION" });
exports.handler = async function (event) {
// Get data from the request sent from the frontend that triggered the lambda
const { firstName, lastName, email, message } = JSON.parse(event.body);
// Config for SES to send the email
const params = {
// Email address the email is sent to
Destination: {
ToAddresses: ["YOUR_VERIFIED_EMAIL"],
},
Message: {
// Body of the email
Body: {
Text: {
Data: `
New message:
---
Name:${firstName} ${lastName}
Email: ${email}
Message: ${message}
`
},
},
// Subject line of the email
Subject: { Data: `Contact Form Message` },
},
// Email address the email is sent from
Source: "YOUR_VERIFIED_EMAIL",
};
// Send the email
return ses.sendEmail(params).promise()
};
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.
CONTACT_FORM_ENDPOINT="YOUR_API_ENDPOINT_HERE"
CONTACT_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.
declare global {
namespace NodeJS {
interface ProcessEnv {
CONTACT_FORM_ENDPOINT: string;
CONTACT_FORM_API_KEY: string;
}
}
}
export {};
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.
{
"compilerOptions": {
...compiler settings
},
"include": [...other files, "additional.d.ts"],
"exclude": [...files]
}
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
.
import 'isomorphic-fetch';
import type { NextApiRequest, NextApiResponse } from 'next';
import { ContactFormValues } from '../../types';
interface ExtendedNextApiRequest extends NextApiRequest {
body: ContactFormValues;
}
interface ExtendedNextApiResponse extends NextApiResponse {
message: string;
}
type TBodyFields = {
[key: string]: string;
};
export default async function contactForm(
req: ExtendedNextApiRequest,
res: ExtendedNextApiResponse
) {
const { body }: { body: TBodyFields } = req;
// Checking we have data from the email input
const requiredFields = ['email', 'firstName', 'lastName', 'message'];
for (const field of requiredFields) {
if (!body[field]) {
res.status(400).json({
message: `Oops! You are missing the ${field} field, please fill it in and retry.`,
});
}
}
// Setting vars for posting to API
const endpoint = process.env.CONTACT_FORM_ENDPOINT;
// posting to the API
await fetch(endpoint, {
method: 'post',
body: JSON.stringify({
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
message: body.message,
}),
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.CONTACT_FORM_API_KEY,
charset: 'utf-8',
},
});
res.status(200).json({ message: 'Success! Thank you for message!' });
}
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.
export type ContactFormValues = {
email: string;
firstName: string;
lastName: string;
message: string;
};
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.
import { SyntheticEvent, useState } from 'react';
import { server } from '../config';
import { ContactFormValues } from '../types';
interface IProps {
values: ContactFormValues;
resetValues: () => void;
}
type Output = {
message: string;
};
export default function useContactForm({ values, resetValues }: IProps) {
// Setting state to be returned depending on the outcome of the submission.
const [loading, setLoading] = useState<boolean>(false);
const [outputMessage, setOutputMessage] = useState<string | null>('');
const [error, setError] = useState<boolean | null>();
// destructuring out the values from values passed to this form.
const { firstName, lastName, email, message } = values;
async function submitContactForm(e: SyntheticEvent) {
// Prevent default function of the form submit and set state to defaults for each new submit.
e.preventDefault();
// Set base state
setLoading(true);
setError(null);
setOutputMessage(null);
// gathering data to be submitted to the serverless function
const body = {
firstName,
lastName,
email,
message,
};
const requiredFields = ['email', 'firstName', 'lastName', 'message'];
// Checking required fields aren't empty.
for (const field of requiredFields) {
if (!field?.length) {
setLoading(false);
setError(true);
setOutputMessage(
`Oops! The field: ${field} is empty, please fill it in and retry.`
);
return;
}
}
// Send the data to the serverless function on submit.
const res = await fetch(`${server}/api/contactForm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const responseText: string = await res.text();
// Waiting for the output of the serverless function and storing into the serverlessBaseoutput var.
const output = (await JSON.parse(responseText)) as Output;
// check if successful or if was an error
if (res.status >= 400 && res.status < 600) {
// Oh no there was an error! Set to state to show user
setLoading(false);
setError(true);
setOutputMessage(output.message);
} else {
// everyting worked successfully.
setLoading(false);
setOutputMessage(output.message);
resetValues();
}
}
return {
error,
loading,
outputMessage,
submitContactForm,
};
}
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.
import { useState } from 'react';
import { ContactFormValues} from '../types';
type UpdateProps = {
target: {
value: string;
name: string;
};
};
export type UseFormUpdateValues = ({
target: { value, name },
}: UpdateProps) => void;
interface ReturnProps {
values: ContactFormValues;
updateValue: UseFormUpdateValues;
resetValues: () => void;
}
export default function useForm(
defaults: ContactFormValues
): ReturnProps {
const [values, setValues] = useState(defaults);
function updateValue({ target: { value, name } }: UpdateProps) {
// Set the value by spreading in the existing values and chaging the key to the new value or adding it if not previously present.
setValues({
...values,
[name]: value,
});
}
function resetValues() {
setValues(defaults);
}
return { values, updateValue, resetValues };
}
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.
import React from 'react';
import useContactForm from '../hooks/useContactForm';
import useForm from '../hooks/useForm';
export default function ContactForm(): JSX.Element {
const { values, updateValue, resetValues } = useForm({
firstName: '',
lastName: '',
email: '',
message: '',
});
const { firstName, lastName, email, message } = values;
const { loading, outputMessage, submitContactForm } = useContactForm({
values,
resetValues,
});
const inputContainerStyles =
'flex flex-col items-start ';
const labelStyles = 'font-bold mb-1 ';
return (
<div className="flex flex-col justify-start items-center p-6 rounded-md col-span-5 xl:col-span-2">
<form
onSubmit={submitContactForm}
className="flex flex-col gap-4 md:gap-6 w-full"
data-testid="contact-form"
>
<div className="grid grid-cols-1 sm:grid-cols-2 w-full gap-4">
<div className={inputContainerStyles}>
<label htmlFor="firstName" className={labelStyles}>
First Name
</label>
<input
type="text"
name="firstName"
id="firstName"
required
placeholder="Your first name"
onChange={updateValue}
value={firstName}
className="rounded-md text-md w-full"
/>
</div>
<div className={inputContainerStyles}>
<label htmlFor="lastName" className={labelStyles}>
Last Name
</label>
<input
type="text"
name="lastName"
id="lastName"
required
placeholder="Your last name"
onChange={updateValue}
value={lastName}
className="rounded-md text-md w-full"
/>
</div>
</div>
<div className={inputContainerStyles}>
<label htmlFor="email" className={labelStyles}>
Email Address
</label>
<input
type="email"
name="email"
id="email"
required
placeholder="Your email"
onChange={updateValue}
value={email}
className="rounded-md text-md w-full"
/>
</div>
<div className={`grow ${inputContainerStyles}`}>
<label htmlFor="message" className={labelStyles}>
Message
</label>
<textarea
name="message"
id="message"
required
placeholder="Your message"
onChange={updateValue}
value={message}
className="rounded-md text-md w-full resize-none h-full max-h-[150px]"
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white text-base font-bold rounded-md py-3 px-5"
>
{loading ? 'Sending..' : 'Send Message'}
</button>
</form>
<p
className={`text-md lg:text-base mt-6 ${
!outputMessage ? 'animate-pulse' : ''
}`}
>
{outputMessage || 'Awaiting Submission...'}
</p>
</div>
);
}
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.
📣This post was originally published on my blog a week prior 📣