DEV Community

Cover image for Serverless Remix App Contact Form with AWS Lambda, AWS SES and Google ReCaptcha
Simon Goldin for Digital Canvas Development

Posted on • Updated on

Serverless Remix App Contact Form with AWS Lambda, AWS SES and Google ReCaptcha

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 which is built on top of the Remix "Grunge Stack".

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 (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. In a nutshell, we're starting with something like this:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

And the core of our action looks like this:

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.',
  });
};
Enter fullscreen mode Exit fullscreen mode

You'll notice two important todos 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):

npm i @aws-sdk/client-ses
Enter fullscreen mode Exit fullscreen mode

@aws-sdk/client-ses is a wrapper around AWS SES v3 and its documentation is here.

**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, the client-ses library will be able to use the AWS keys from the environment variables that were set in the .deploy script.

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:

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);
};
Enter fullscreen mode Exit fullscreen mode

And an updated action:

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.'
  });
};
Enter fullscreen mode Exit fullscreen mode

At this point, you can enable SES and finish the AWS set up as described here.

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. 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

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

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 with npm i react-google-recaptcha.

Import it on the page:

import ReCAPTCHA from 'react-google-recaptcha';
Enter fullscreen mode Exit fullscreen mode

and render it in the component:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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}`,
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

and access the data from the component with useLoaderData:

  const data = useLoaderData<{ ENV: Pick<Globals, 'CAPTCHA_SITE_KEY'> }>();
Enter fullscreen mode Exit fullscreen mode

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

<ReCAPTCHA
  ref={recaptchaRef}
  onChange={handleRecaptchaChange}
+ sitekey={data.ENV.CAPTCHA_SITE_KEY}
/>
Enter fullscreen mode Exit fullscreen mode

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

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...
};
Enter fullscreen mode Exit fullscreen mode

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

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();
};
Enter fullscreen mode Exit fullscreen mode

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

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 or SendGrid 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 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.

Top comments (0)