React + AWS + Terraform Tutorial: Deploying a Serverless Contact Form

React + AWS + Terraform Tutorial: Deploying a Serverless Contact Form

This tutorial walks you through creating a simple contact form front-end web application with React. The contact form will be connected to a serverless AWS backend, leveraging Amazon Simple Email Service, AWS Lambda and API Gateway. When you submit the content on the frontend application, the contents are sent to your email.

Before you begin

This tutorial utilizes several tools and requires familiarity with basic tools like Git, as well as a basic understanding of frontend development, and knowledge about AWS services and infrastructure as code. The main purpose of this tutorial is to build a small project where all of these technologies are connected. If any of these tools is new to you and feels challenging, it is a great chance to go and read some documentation and then gain some practical experience by following this tutorial.

This project has a simple folder structure, where the main component is the Terraform code that is used to create the AWS resources needed to run the serverless backend. We will also have folders to store the Lambda code and frontend code in the same repository:

React

The React project is going to be simple to limit the length of this tutorial, but you could of course easily extend the frontend application. We will use a React UI library called Mantine to create the contact form. Mantine has several pre-built components and styles and we can create a simple form easily.

The easiest way to get started with Mantine is by using one of their ready Vite templates. You can read more about it here and find the Vite template here. Vite is a front-end tool that can be used to create React apps. The template includes all React and Mantine UI dependencies, so by using this template you don't need to do any further installations and it is a quick way to get started.

After you have used the template to create a new repository and created a local project from it, you can install dependencies (npm install) and run the project (npm run dev). The home page contains a mock page and to keep this project really simple, we will add our contact form directly on the homepage.

Mantine UI has a ready form that we can use, we only need to add some logic to make it work. To use Mantine form, you first need to run the following installation npm install @mantine/form and after that, you are ready to replace the code in your Home.page.tsx -file with the following code:

import { useForm } from '@mantine/form';
import {
  TextInput,
  Textarea,
  SimpleGrid,
  Group,
  Title,
  Button
} from '@mantine/core';

export function HomePage() {
  const form = useForm({
    initialValues: {
      name: '',
      email: '',
      subject: '',
      message: '',
    },
    validate: {
      name: (value) => value.trim().length < 2,
      email: (value) => !/^\S+@\S+$/.test(value),
      subject: (value) => value.trim().length === 0,
    },
  });

  interface FormValues {
    name: string;
    email: string;
    //subject: string;
    message: string;
  }

  const handleSubmit = async (values: FormValues) => {
    try {
      // Replace with your actual API endpoint URL
      const apiUrl = 'https://12345.execute-api.eu-west-2.amazonaws.com/test'; 

      const response = await fetch(apiUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(values),
      });

      if (response.ok) {
        // Request successful, do something here
        console.log('Email sent successfully!');
      } else {
        // Request failed, handle errors here
        console.error('Error sending email.');
      }
    } catch (error) {
      console.error('An error occurred:', error);
    }
  };

  return (
    <form onSubmit={form.onSubmit(handleSubmit)}>
      <Title
        order={2}
        size="h1"
        style={{ fontFamily: 'Greycliff CF, var(--mantine-font-family)' }}
        fw={900}
        ta="center"
      >
        Get in touch
      </Title>

      <SimpleGrid cols={{ base: 1, sm: 2 }} mt="xl">
        <TextInput
          label="Name"
          placeholder="Your name"
          name="name"
          variant="filled"
          {...form.getInputProps('name')}
        />
        <TextInput
          label="Email"
          placeholder="Your email"
          name="email"
          variant="filled"
          {...form.getInputProps('email')}
        />
      </SimpleGrid>

      <TextInput
        label="Subject"
        placeholder="Subject"
        mt="md"
        name="subject"
        variant="filled"
        {...form.getInputProps('subject')}
      />
      <Textarea
        mt="md"
        label="Message"
        placeholder="Your message"
        maxRows={10}
        minRows={5}
        autosize
        name="message"
        variant="filled"
        {...form.getInputProps('message')}
      />

      <Group justify="center" mt="xl">
        <Button type="submit" size="md">Send message</Button>
      </Group>
    </form>
  );
}

Your project now has a simple contact form that will validate the form fields:

Submitting the form will try to send the data to the apiUrl defined above, which won't work yet as we haven't implemented the serverless backend. While this tutorial walks you through setting up the front-end app locally, we'll design it to seamlessly connect with the serverless backend once it's deployed.

AWS

The creation of AWS resources requires you to have an AWS account. You also need to create credentials for your AWS account to be used with Terraform in the next section. Additionally, there are a couple of steps we are going to be doing directly on the AWS console.

As the goal of this project is to create a contact form that will send the contents to an email account, we will need an email address for this and this email account needs to be verified by AWS. The easiest way to do this is in the Amazon SES (Simple Email Service) console. Simply click 'verified identities' - 'create identity' and follow the instructions. The identity status of your email must be 'verified' for this project to work:

Another setup step in the console is creating an S3 bucket that is used for storing the Lambda code. This could be done using AWS CLI or by creating a script, but the easiest way for now is to do this directly in the console. You can create a bucket manually and make a note of the name of the bucket, as we are going to need it for our Terraform code. Now, if you followed the instructions in the beginning, you have an aws-lambda folder where you can create a file called index.mjs. Below is the Lambda code you can add to the file:

import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
var ses = new SESClient({ region: "eu-west-2" }); // Change here your region

export const handler = async (event) => {
  const eventData = JSON.parse(event.body);

  const email = eventData.email;
  const name = eventData.name;
  const message = eventData.message;


  const emailBody = `Hello,\n\nYou have received a new message via the 
    contact form on your website. Below are the details of the message:
    \n\n**Sender Information:**\n- Name: ${name}\n- Email: ${email}\n\n
    **Message:**\n${message}\n\nPlease respond to this message at your 
    earliest convenience.`


  const command = new SendEmailCommand({
    Destination: {
     //Change here your destination email address
      ToAddresses: ['email@example.com'],
    },
    Message: {
      Body: {
        Text: { Data: emailBody },
      },

      Subject: { Data: "New Message from Contact Form" },
    },
    Source: 'email@example.com', //Add here your source email address
  });


  try {
    let response = await ses.send(command);

    response = {
      statusCode: 200,
      headers: {
        //Change here the URL where your frontend app is running:
        "Access-Control-Allow-Origin": "http://localhost:5173", 
        "Access-Control-Allow-Headers": "Content-Type",
        "Access-Control-Allow-Methods": "POST, OPTIONS"
      },
    };

    return response;


  } catch (error) {
    console.error('Error:', error);


    return {
      statusCode: 500,
      body: JSON.stringify({ message: 'Internal server error' }),
    };
  }
};

As you can see, there are a couple of things you need to modify in this code. First of all, change the region to your preferred AWS region. You also need to change the email address to the one you have added and verified through the AWS console (you can use the same email address as 'to' and 'from' email to keep things simple). For the CORS settings, you also need to specify the URL address where your front-end application will be running. This setting is crucial to prevent unauthorized access to your API, as it ensures that only authorized domains can send requests to your backend

To store the code in the S3 bucket, you first need to manually zip the file as having the file in a zipped format is a requirement for Lambda deployment. Name the zipped folder as lambda.zip as that's how it will be referred to later in the code. Now upload this zipped file to the S3 bucket you have created previously. Your Lambda code will now be ready in the AWS cloud to be used by your Terraform code when we start creating the resources.

Terraform

The infrastructure of our project will be provisioned using Terraform. This tutorial assumes you have created an account for Terraform Cloud, installed Terraform CLI and created an organization and workspace on your Terraform Cloud account. Furthermore, as we are creating AWS resources with Terraform, we need access to the AWS account to be able to do this. Terraform can be used for local or remote execution and the local execution would require local configuration. However, in this tutorial, we are using Terraform with remote execution, which means you need to add your AWS credentials as environment variables for the Terraform Cloud workspace:

At this point, you should have all the setup ready to be able to start adding the code for the resources and start running Terraform commands from your account.

We will now start adding code to describe the AWS resources. We start with Lambda, as we in the previous steps uploaded the actual code for the function itself. In the modules/serverless-backend-aws folder, create a new file called resource-lambda.tf:

resource "aws_lambda_function" "serverless-contact-form-lambda" {
  function_name = "ServerlessContactForm"

  # change the name of the S3 bucket to the one you have 
  # created through the console
  s3_bucket = "serverless-contact-form-lambda"
  s3_key    = "lambda.zip"

  handler = "index.handler"
  runtime = "nodejs18.x"

  role = "${aws_iam_role.lambda_exec.arn}"
}

resource "aws_iam_role" "lambda_exec" {
  name = "serverless_contact_form"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
# inline policy in order to access SES
 inline_policy {
    name = "SESPermissionsPolicy"
    policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ses:SendEmail",
        "ses:SendRawEmail"
      ],
      "Resource": "*"
    }
  ]
}
EOF
  }
}


resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = "${aws_lambda_function.serverless-contact-form-lambda.function_name}"
  principal     = "apigateway.amazonaws.com"

  source_arn = "${aws_api_gateway_rest_api.serverless-contact-form-api.execution_arn}/*/*" 
}

This code creates for us three resources:

  • the Lambda function itself. As you can see, the code that will be populated within the function is taken from an S3 bucket. Change here the name of the S3 bucket you have created previously

  • Lambda execution role to permit Lambda to send emails via SES

  • Permission for API Gateway to invoke the Lambda function

Next you can create another file in the same folder: resource-api-gateway.tf:

resource "aws_api_gateway_rest_api" "serverless-contact-form-api" {
  name = "serverless-contact-form-api"
  description = "API Gateway for the serverless contact form"
}


resource "aws_api_gateway_integration" "lambda" {
  rest_api_id = "${aws_api_gateway_rest_api.serverless-contact-form-api.id}"
  resource_id = "${aws_api_gateway_method.proxy_root.resource_id}" 
  http_method = "${aws_api_gateway_method.proxy_root.http_method}" 

  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = "${aws_lambda_function.serverless-contact-form-lambda.invoke_arn}"
}

resource "aws_api_gateway_method" "proxy_root" {
  rest_api_id   = "${aws_api_gateway_rest_api.serverless-contact-form-api.id}"
  resource_id   = "${aws_api_gateway_rest_api.serverless-contact-form-api.root_resource_id}"
  http_method   = "POST"
  authorization = "NONE"
}


resource "aws_api_gateway_deployment" "test" {
  depends_on = [
    "aws_api_gateway_integration.lambda",
  ]

  rest_api_id = "${aws_api_gateway_rest_api.serverless-contact-form-api.id}"
  stage_name  = "test"
}

This creates for us four resources:

  • the API gateway called 'serverless-contact-form-api'

  • an integration with the Lambda function for the POST method

  • a POST method

  • API Gateway deployment stage called 'test'

Another file that we need in this folder is called outputs.tf. In this file, we are going to define outputs that we want to expose outside of this module after the infrastructure has been provisioned. In this case, there will be an output for the API gateway and API Gateway URL. We need to refer to the APi Gateway resource in main.tf and that is why we need to expose it as an output. However, the API Gateway URL is not referred to in the main.tf file, instead, we imply want to print it in the console as we are going to need it for the API call in the frontend application.

output "api_gateway_contact_form" {
  value = aws_api_gateway_rest_api.serverless-contact-form-api
}
output "api_gateway_url" {
  value = aws_api_gateway_deployment.test.invoke_url
}

Now we have created the files that define what resources we want to create at AWS. We still need to add some general configuration to explain to Terraform what exactly we want it to do. This is done in main.tf file:

terraform {
  cloud {
    organization = "MyOrganization" #Add here your organization
    workspaces {
      name = "contact-form" #Add here your workspace name
    }
  }
}

provider "aws" {
  region = "eu-west-2" #Add here your region
}

module "serverless-backend-aws" {
  source = "./modules/serverless-backend-aws"
}

module "cors" {
  source = "squidfunk/api-gateway-enable-cors/aws"
  version = "0.3.3"

  api_id          = module.serverless-backend-aws.api_gateway_contact_form.id
  api_resource_id = module.serverless-backend-aws.api_gateway_contact_form.root_resource_id
  allow_headers = ["Content-Type"]
  allow_methods = ["OPTIONS", "POST"]
    #Add here the URL where your frontend application is running:
  allow_origin = "http://localhost:5173" 
}

The first thing this file tells Terraform is the details of the Terraform Cloud workspace we want to use for this project. For it to access your workspace in the cloud, you need to log in from the command line by using Terraform login.

The provider is the plugin that allows Terraform to interact with the API of the specific service provider. As we are creating services at AWS, we will be using the AWS provider for this.

Lastly, we need to list all of the modules we want Terraform to create for us. In this tutorial, we are creating a module for the serverless backend, additionally, we could add here a module for the frontend as well if we were deploying it with this same infrastructure.

We also create a separate module for CORS settings, which leverages a community-provided module that simplifies the CORS configuration for API Gateway. We need to refer here to the API Gateway resource we created and this is why we in the previous steps added that to to the outputs.tf in the serverless-backend-aws module.

The below diagram summarizes the different parts of the Terraform code:

The last file needed at the project root level is outputs.tf, just like the one we had in the module folder. We want to refer here to the one value we want to print in the console, namely the URL of the API Gateway. Once the backend has been deployed, we want to add this URL to the front-end application

output "api_gateway_url" {
    description = "Bucket name for our static website hosting"
    value = module.serverless-backend-aws.api_gateway_url
}

Finalizing the Project

It is now time to run Terraform plan and Terraform deploy to create the serverless backend. You can monitor in the terminal whether the creation of all resources is successful and you also see your resources listed in the Terraform cloud console. If all goes well, you will see your API Gateway URL printed in the terminal and can add that to your front-end application. Now submitting the form should be successful and the form data should arrive to your email:

If you end up getting an error, it is probably a small mistake you have made somewhere along the way and a great opportunity to dig in and learn more about the services. It could be something as simple as not adding the correct origin URL to CORS settings - going to the AWS console and testing the Lambda function and API Gateway in isolation might help you find out where the issue is between the frontend, API Gateway, Lambda and SES.

Next Steps

Now that you have the simple contact form working, you could do several things to extend and improve this project. As already touched upon previously, if frontend development is your thing, you could build the React application into a real application for example by leveraging Mantine UI's AppShell and other components. The form itself would also need some improvements - you could add error handling and improve the user experience for example with a notification to show when the form has been submitted.

You could also figure out how to deploy the front end on S3 and CloudFront and connect it to your custom domain. This could be done by adding an additional front-end module alongside the serverless-backend module. See my repository for some tips and example code for this.

The Lambda code could also be improved, it doesn't for example have any error handling at the moment. There could also be a more automated way of uploading the code rather than zipping the code manually and uploading it to the S3 bucket.

After making some improvements to the project, you could then move on to automating the deployment for example by using GitHub Actions. You would define the deployment steps in a workflow file and configure triggers for actions (for example push events could trigger a new deployment).

I hope this tutorial has helped you build your first serverless contact form. My code is available in this repository.