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>
);
};
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.',
});
};
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):
npm i @aws-sdk/client-ses
@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);
};
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.'
});
};
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).
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.
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';
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>
);
};
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}`,
}
});
};
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:
<ReCAPTCHA
ref={recaptchaRef}
onChange={handleRecaptchaChange}
+ sitekey={data.ENV.CAPTCHA_SITE_KEY}
/>
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...
};
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();
};
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!):
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)