Serverless Remix App Contact Form with AWS Lambda, AWS SES and Google ReCaptcha
Tags: node, remix, webdev, typescript
Published: 2023-06-19 16:11:41

# Introduction

This blog post revisits my (apparent) "Check out this new React framework" series that I've strayed away from.

Specifically, I'm going to go through a contact form implementation adapted from working on the [Digital Canvas Development website](https://digitalcanvas.dev) which is built on top of the Remix "<a href="https://github.com/remix-run/grunge-stack" target="_blank">Grunge Stack</a>".

The entire website, including the specifics covered here, are available on github: https://github.com/digital-canvas-dev/digitalcanvas.dev

# Project overview

My application is a simple site that will act as a landing page for my new business. It's mainly composed of static content and a contact form.

Remix is a full-stack framework that co-locates server-side and client-side code. The Grunge Stack comes with a host of features and libraries including AWS deployment via [Architect](https://arc.codes/) (e.g. `npx arc `). Many of the AWS tools are free-tier eligible for low-traffic sites.

This guide assumes you've already deployed your application or you're ready to.

To get the contact form working end-to-end, I'll be using AWS Simple Email Service (SES). To prevent spam, I will be using Google ReCaptcha (v2).

# Component setup

The initial form looks very similar to form portion of the official [Remix "blog" tutorial](https://remix.run/docs/en/main/tutorials/blog#actions). In a nutshell, we're starting with something like this:

```tsx
import { Form, useActionData, useSubmit } from '@remix-run/react';
import { InputText } from 'my-component-library';

export const Contact = () => {
  const actionData = useActionData();

  const submit = useSubmit();

  const onSubmit = async (e) => {
    await submit(e.currentTarget);
  };

  return (
    <section>
      <Form method="POST" onSubmit={onSubmit}>
        <InputText
          name="name"
          label="Name"
          errorFeedback={actionData?.errors?.name ?? null}
        />
        {/* ... other fields ... */}
        <button type="submit">Send</button>
      </Form>
    </section>
  );
};
```

And the core of our `action` looks like this:

```ts
export const action = async ({ request }) => {
  const formData = await request.formData();

  // we'll pseudocode this away.
  // it will return an object of errors, if any.
  const errors = validate(formData);

  if (errors) {
    return errors;
  }

  // todo: avoid spam
  // todo: send the email

  return json({
    success: true,
    successMessage: 'Message sent! Expect to hear back soon.',
  });
};
```

You'll notice two important `todo`s which we'll be addressing in reverse order so we can tackle the more interesting parts first 😀

# Sending emails with AWS Simple Email Service (SES)

To integrate with SES, I pulled in the AWS SDK (v3):

```sh
npm i @aws-sdk/client-ses
```

`@aws-sdk/client-ses` is a wrapper around AWS SES v3 and its documentation is [here](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-ses/).

**Important note: your lambda function must use nodeJS v18 to use the AWS v3 SDK. Otherwise, you'll need to use the v2 SDK.

One of the cool thing about using AWS for email architecture with the Grunge Stack is that as long as you already followed the [deployment steps](https://github.com/remix-run/grunge-stack#deployment), the `client-ses` library will be able to use the AWS keys from the environment variables that were set in the [.deploy script](https://github.com/remix-run/grunge-stack/blob/c64891d437a6d82a9c17ffbd3b70efe22c931fc1/.github/workflows/deploy.yml#L160-L161).

Now (after looking at a lot of interfaces and documentation) the `action` implementation becomes clear: we can simply create an instance of the SESClient, and use it to send an email!

**Architecture note:**
> We can break these methods into a few logical (and reusable, and testable) parts.
> 
> This way, we can maintain the instantiation in one place and reuse it later.

Putting it all together, the logic might look like this...

A server-only `ses.server.ts` file:
```typescript
import { ActionArgs, json } from '@remix-run/node';
import { SendEmailCommand, SendEmailCommandInput, SESClient } from '@aws-sdk/client-ses';

export const sendEmail = async (params: SendEmailCommandInput) => {

  const sesClient = new SESClient({
    region: 'us-east-1',
  });

  const command = new SendEmailCommand(params);

  return await sesClient.send(command);
};
```

And an updated action:

```tsx
export const action = async ({ request }: ActionArgs): Promise<{ success: true, successMessage: string; } | {
  success: false,
  errors: { form: string }
}> => {
  const formData = await request.formData();

  const requesterName = formData.get('name');

  const params = {
    Source: 'no-reply@...',
    Destination: {
      ToAddresses: ['...']
    },
    Message: {
      Subject: {
        Data: 'Form submission'
      },
      Body: {
        Html: {
          Data: `Someone submitted the contact form: Name ${requesterName}, Email: ...`
        }
      }
    }
  };

  const resp = await sendEmail(params);

  const sentError = resp.$metadata.httpStatusCode === 200 ? null : { form: 'Error sending email.' };

  if (sentError) {
    return json({
      success: false,
      errors: sentError
    });
  }

  return json({
    success: true,
    successMessage: 'Thank you for reaching out! Expect to hear back soon.'
  });
};
```

At this point, you can enable SES and finish the AWS set up as described [here](https://repost.aws/knowledge-center/lambda-send-email-ses).

Once your domain is verified and set up is complete, you can send a test email from your dev environment!

# Preventing spam with Google ReCaptcha v2

However, deploying at this point is risky. Even if you're only sending emails to your own email address, a malicious actor might try to continuously submit your form which can rate-limit you or rack up your AWS bill.

In lieu of (or in addition to) site-wide spam prevention, we'll make sure that the form is submitted by a person by adding a checkbox captcha. I decided not to use v3, because frankly, I didn't like the "protected by reCAPTCHA" badge in the bottom-right corner.

First, you'll need to create a ReCaptcha [here](https://www.google.com/u/0/recaptcha/admin/). Fill in a label, select `Challenge (v2)` and `"I'm not a robot" Checkbox` and add the domain name the captcha will be on (while you're here, it's a good idea to create one for `localhost` and another for a staging environment, if you're using it).

![example of a new recaptcha being created](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hne3o9w2shn97qvzfrih.png)

You'll be presented with "site key" and "secret key" that you can add to your 1) `.env` file, and 2) github repo settings.

I named them `CAPTCHA_SITE_KEY` and `CAPTCHA_SECRET` respectively.

![example of captcha values being added to github environment secrets and variables settings page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r24y39yz1v0w3du4y7wn.png)

The site key will link the site where the form is to ReCaptcha, and the secret key will only be used server-side to validate the value generated by ReCaptcha in the browser.

Next, we'll install the [`react-google-recaptcha` library](https://github.com/dozoisch/react-google-recaptcha) with `npm i react-google-recaptcha`.

Import it on the page:

```
import ReCAPTCHA from 'react-google-recaptcha';
```

and render it in the component:

```diff
export const Contact = () => {
+ const [recaptchaValue, setRecaptchaValue] = useState<string | null>(null);
+ const recaptchaRef = useRef<ReCAPTCHA>(null);

  const actionData = useActionData();

  const submit = useSubmit();

  const onSubmit = async (e) => {
    await submit(e.currentTarget);
+   setRecaptchaValue(null);
+   recaptchaRef?.current?.reset();
  };

+ const handleRecaptchaChange = (value: string | null) => {
+   setRecaptchaValue(value);
+ };

  return (
    <section>
      <Form method='POST' onSubmit={onSubmit}>
        <InputText
          name='name'
          label='Name'
          errorFeedback={actionData?.errors?.name ?? null}
        />
        {/* ... other fields ... */}
+       <input type='hidden' name='recaptchaValue' value={recaptchaValue} />
+       <ReCAPTCHA
+         ref={recaptchaRef}
+         onChange={handleRecaptchaChange}
+       />
        <button type='submit'>Send</button>
      </Form>
    </section>
  );
};
```

For this to work, the `CAPTCHA_SITE_KEY` needs to be accessible by the component, so we can use a loader:

```tsx
export const loader = async (): Promise<TypedResponse<{ ENV: Pick<Globals, 'CAPTCHA_SITE_KEY'> }>> => {
  return json<{
    ENV: Pick<Globals, 'CAPTCHA_SITE_KEY'>;
  }>({
    ENV: {
      CAPTCHA_SITE_KEY: `${process.env.CAPTCHA_SITE_KEY}`,
    }
  });
};
```

and access the data from the component with `useLoaderData`:

```
  const data = useLoaderData<{ ENV: Pick<Globals, 'CAPTCHA_SITE_KEY'> }>();
```

Lastly (for the component), we'll fill in the real `sitekey` prop:

```diff
<ReCAPTCHA
  ref={recaptchaRef}
  onChange={handleRecaptchaChange}
+ sitekey={data.ENV.CAPTCHA_SITE_KEY}
/>
```

---

We'll need to update the action again, to validate the ReCaptcha:

```diff
export const action = async ({ request }: ActionArgs): Promise<{ success: true, successMessage: string; } | {
  success: false,
  errors: { form: string }
}> => {
  const formData = await request.formData();

+ const recaptchaValue = formData.get('recaptchaValue');
+
+ const captchaResponse = await validateCaptcha(recaptchaValue);
+
+ if (!captchaResponse.success) {
+   return json({
+     success: false,
+     errors: {
+       recaptchaValue: 'Invalid ReCAPTCHA response.',
+     },
+   });
+ }

  const requesterName = formData.get('name');
  
  // params, etc...
};
```

We can create the `validateCaptcha` function and put it in a `captcha.server.ts` file:

```ts
const ReCaptchaURL = 'https://www.google.com/recaptcha/api/siteverify';
export const validateCaptcha = async (
  recaptchaValue: FormDataEntryValue | null
) => {
  const captchaResponse = await fetch(ReCaptchaURL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `secret=${process.env.CAPTCHA_SECRET}&response=${recaptchaValue}`,
  });

  return await captchaResponse.json();
};
```

`CAPTCHA_SECRET` will be pulled from your build or .env file and there's nothing left to do!

A complete implementation, with more fields and some styling might look like this (at least mine does, as of this writing!):

![screenshot of digitalcanvas.dev contact form](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l8qc03sxc0fb4ga8iui6.png)

# Outro 

Thank you for reading! Tying in these different SDKs into a new application touched on a lot of things I've done in the past, but not all together in a Remix application. As the web has progressed, developers have been given so many options and different (paid and often free) ways of doing things, and nearly infinite opportunities to learn new things.

That being said, consider alternative 3rd party tools such as [MailChimp](https://mailchimp.com/) or [SendGrid](https://sendgrid.com/) which are more flexible (not tied to AWS and not part of the codebase) and more approachable (they can be configured without needing to code).

Props to [this article](https://reiland.dev/blog/serverless-contact-form-lambda/) that I found after building a working proof of concept which gave me some ideas for improvements (for example, I didn't know that `client-ses` had built-in authentication and my first version was managing the AWS configuration manually!). Even 13 years into this, I'm glad that I'm always learning.