Welcome back to my blog.
We all know the benefits that building a following online can bring. And, one of the most powerful tools for someone looking to build a following online is an email newsletter.
But, just having a newsletter isn't enough, we also need a way for people to sign up to it with minimal effort.
That's why in this post, I'm going to show you how I built a custom email newsletter signup form for ConvertKit on my GatsbyJS website. Let's do this.
There are 4 parts to building a custom subscriber form, these are:
- The signup component users will interact with.
- A custom hook to handle the form changes.
- A custom hook to handle the submitting of the form.
- A serverless function to actually submit the request.
Let's cover each one individually and see how the data flows between them.
The Signup Component
Because we are just building an email signup form the only inputs we need is a text input for the email and a submit button.
Here's a look at the code:
export const EmailSignup = () => {
const { values, updateValue } = useForm({
email: ''
});
const { message, loading, error, submitEmail } = useEmail({ values });
const { email } = values;
return (
<>
<FormGridContainer onSubmit={submitEmail}>
<fieldset disabled={loading}>
<label htmlFor="email">
Email:
<input
type="email"
name="email"
id={`email-${Math.random().toString(36).substring(2, 15)}`}
className="emailInput"
onChange={updateValue}
value={email}
/>
</label>
</fieldset>
<button className="signupButton" type="submit" disabled={loading}>
{loading ? 'Subscribing...' : ' Subscribe'}
</button>
</FormGridContainer>
{message ? <OutcomeMessageContainer error={error} message={message} /> : ''}
</>
);
};
In the first part, we handle passing the data to and from the 2 helper functions that we will create: useForm
and useEmail
.
Then for the rest of the component, we handle displaying the data back to the user in the form and creating elements for them to interact with.
The only other part to note is at the bottom of the code. The component OutcomeMessageContainer
is a styled-component that looks like this:
const OutcomeMessageContainer = ({ error, message }) => (
<MessageContainer>
{error ? <FaTimes data-error /> : <FaCheck />}
<p>{message}</p>
</MessageContainer>
);
As you can see we pass in 2 props, the error if there is one and the message returned back from the serverless function. we then display these to the user.
Now, let's look at the first helper function: useForm
.
useForm
useForm
is a small helper function to aid in the recording and displaying of information in the form.
It expands to include new values if required so all we need to do is pass in new defaults.
This is important because we want an easy way to access the data to pass to the next helper function useEmail
.
Here's the code for useForm
.
import { useState } from "react";
export default function useForm(defaults) {
const [values, setValues] = useState(defaults);
function updateValue(e) {
// Get value from the changed field using the event.
const { value } = e.target;
// 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,
[e.target.name]: value,
});
}
return { values, updateValue };
}
Essentially, it boils down to a useState
hook and a function to set the state.
The state it sets is an object containing the current values and any added ones.
For example, in our case the object set to the state would look like:
{
email: "example@example.com";
}
If we then look back at our original component where we consume this hook you can see how we use it:
const { values, updateValue } = useForm({
email: "",
});
const { email } = values;
First, we destructure out the values
and the updateValue
function that we returned. Then we destructure out the individual values beneath that.
When calling the hook we have to provide some default values for the hook to set to state.
We do this because otherwise when we access the email
value on page load, it won't exist causing an error. To prevent this we create all the required state on load with a default value.
We then update this state as required.
Then on the input element in the form, we pass the updateValue
function as the onChange
handler like so:
<input
type="email"
name="email"
id={`email-${Math.random().toString(36).substring(2, 15)}`}
className="emailInput"
onChange={updateValue}
value={email}
/>
How does it know what index to update you may be asking?
Well, looking back at our useForm
code, in the updateValue
function:
function updateValue(e) {
// Get value from the changed field using the event.
const { value } = e.target;
// 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,
[e.target.name]: value,
});
}
Here you can see that we destructure out the value
we want to set to state from the event with e.target
. Then when we set the state we get the name
of the input from e.target
again to be the key.
Looking at the above code, the name of the input element is email
.
This will update the state with the key email
with the value from the target element.
To summarise:
- We pass in a default state for
email
as an empty string. - Then use an
onChange
handler to update this state at a later date as the user begins to type in it.
useEmail
Let's take a look at the next helper function.
Here's the entire code which we will break down in a second:
import { useState } from "react";
export default function useEmail({ values }) {
// Setting state to be returned depending on the outcome of the submission.
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState();
// destructuring out the values from values passed to this form.
const { email } = values;
const serverlessBase = process.env.GATSBY_SERVERLESS_BASE;
async function submitEmail(e) {
// Prevent default function of the form submit and set state to defaults for each new submit.
e.preventDefault();
setLoading(true);
setError(null);
setMessage(null);
// gathering data to be submitted to the serverless function
const body = {
email,
};
// Checking there was an email entered.
if (!email.length) {
setLoading(false);
setError(true);
setMessage("Oops! There was no email entered");
return;
}
// Send the data to the serverless function on submit.
const res = await fetch(`${serverlessBase}/emailSignup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
// Waiting for the output of the serverless function and storing into the serverlessBaseoutput var.
const output = JSON.parse(await res.text());
// 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);
setMessage(output.message);
} else {
// everyting worked successfully.
setLoading(false);
setMessage(output.message);
}
}
return {
error,
loading,
message,
submitEmail,
};
}
It's a bit of a long one but it breaks down into logical chunks:
- We create some state with default values, destructure values from the props and get the serverless base from our
.env
export default function useEmail({ values }) {
// Setting state to be returned depending on the outcome of the submission.
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState();
// destructuring out the values from values passed to this form.
const { email } = values;
const serverlessBase = process.env.GATSBY_SERVERLESS_BASE;
// ... Rest of function
}
At this point the function hasn't been called, so we create the state accordingly:
loading -> false -> Not waiting on anything as not called
message -> "" -> No info returned so blank by default
error -> <EMPTY> -> No error has been generated as not called.
Then we destructure out the value we're interested in from the props email
. This will be passed to the body of the submission request in a moment.
Then we get the serverless base from the .env
file.
Learn more about Serverless Netlify Functions.
Defining the submitEmail function
Now, we're going to look at our submitEmail
function, this is what will be called when the form is submitted. (We'll come back to this in a moment.)
async function submitEmail(e) {
// Prevent default function of the form submit and set state to defaults for each new submit.
e.preventDefault();
setLoading(true);
setError(null);
setMessage(null);
// gathering data to be submitted to the serverless function
const body = {
email,
};
// Checking there was an email entered.
if (!email.length) {
setLoading(false);
setError(true);
setMessage("Oops! There was no email entered");
return;
}
// Send the data to the serverless function on submit.
const res = await fetch(`${serverlessBase}/emailSignup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
// Waiting for the output of the serverless function and storing into the output var.
const output = JSON.parse(await res.text());
// 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);
setMessage(output.message);
} else {
// everyting worked successfully.
setLoading(false);
setMessage(output.message);
}
}
Once again, let's break this down into steps.
- First we prevent the default behaviour of the form and update the state values we defined earlier on to show we're waiting on the request.
// Prevent default function of the form submit and set state to defaults for each new submit.
e.preventDefault();
setLoading(true);
setError(null);
setMessage(null);
- We create the body of the request we're going to submit by using the values from earlier.
// gathering data to be submitted to the serverless function
const body = {
email,
};
- Before submitting the form, we check if the email length is greater than 0 or is truthy. If it isn't then we update the state to be an error, pass a custom error message and return the function.
// Checking there was an email entered.
if (!email.length) {
setLoading(false);
setError(true);
setMessage("Oops! There was no email entered");
return;
}
- If the email value is truthy, then we progress with the submission and do a
POST
request to the serverless function with thebody
we created.
// Send the data to the serverless function on submit.
const res = await fetch(`${serverlessBase}/emailSignup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
- We await the reply from the serverless function, once received we convert it to text and parse it with
JSON.parse
.
// Waiting for the output of the serverless function and storing into the output var.
const output = JSON.parse(await res.text());
- Then we get to the final part of the submit function. We check if the request was successful or not and set the state accordingly.
// 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);
setMessage(output.message);
} else {
// everyting worked successfully.
setLoading(false);
setMessage(output.message);
}
Returning data
After we have processed the request, we return the below info back out of the helper function:
return {
error,
loading,
message,
submitEmail,
};
This gives us access to all the state we defined as well the submitEmail
function we defined.
Using it in the Component
Back in the main component, we destructure out the values from the useEmail
function like so:
const { message, loading, error, submitEmail } = useEmail({ values });
We consume the destructured values in the following places:
onSubmit
function for the form
<FormGridContainer onSubmit={submitEmail}>
- Disabling the submit button if loading is true and changing the text within it.
<button className="signupButton" type="submit" disabled={loading}>
{loading ? "Subscribing..." : " Subscribe"}
</button>
- We use the display component from earlier to show the message to the user and whether there has been an error or not.
{
message ? <OutcomeMessageContainer error={error} message={message} /> : "";
}
Now we just need to look at the serverless function.
The Serverless Function
Let's now take a look at how we're going to use the information from useEmail
in our serverless function to submit the request to ConvertKit.
But, before you can do this you'll need to get yourself an API key and create a form on ConvertKit. If you want to read more about their API, click here.
The API endpoint we will be making use of is:
https://api.convertkit.com/v3/forms/
Once you have your form id from the form you created and your API key we can get started.
Here is the full code:
require("isomorphic-fetch");
exports.handler = async (event) => {
const body = JSON.parse(event.body);
// Checking we have data from the email input
const requiredFields = ["email"];
for (const field of requiredFields) {
if (!body[field]) {
return {
statusCode: 400,
body: JSON.stringify({
message: `Oops! You are missing the ${field} field, please fill it in and retry.`,
}),
};
}
}
// Setting vars for posting to API
const endpoint = "https://api.convertkit.com/v3/forms/";
const APIKey = process.env.CONVERTKIT_PUBLIC_KEY;
const formID = process.env.CONVERTKIT_SIGNUP_FORM;
// posting to the Convertkit API
await fetch(`${endpoint}${formID}/subscribe`, {
method: "post",
body: JSON.stringify({
email: body.email,
api_key: APIKey,
}),
headers: {
"Content-Type": "application/json",
charset: "utf-8",
},
});
return {
statusCode: 200,
body: JSON.stringify({ message: "Success! Thank you for subscribing! ๐" }),
};
};
Let's walk through this again as we did with the other functions:
- Parse the JSON body that was sent from
useEmail
.
const body = JSON.parse(event.body);
- Check we have filled the required fields, if not return an error saying they were missed.
// Checking we have data from the email input
const requiredFields = ["email"];
for (const field of requiredFields) {
if (!body[field]) {
return {
statusCode: 400,
body: JSON.stringify({
message: `Oops! You are missing the ${field} field, please fill it in and retry.`,
}),
};
}
}
- Get our variables from
.env
and then submit aPOST
request to ConvertKit.
// Setting vars for posting to API
const endpoint = process.env.CONVERTKIT_ENDPOINT;
const APIKey = process.env.CONVERTKIT_PUBLIC_KEY;
const formID = process.env.CONVERTKIT_SIGNUP_FORM;
// posting to the Convertkit API
await fetch(`${endpoint}${formID}/subscribe`, {
method: "post",
body: JSON.stringify({
email: body.email,
api_key: APIKey,
}),
headers: {
"Content-Type": "application/json",
charset: "utf-8",
},
});
- Process the return
return {
statusCode: 200,
body: JSON.stringify({ message: "Success! Thank you for subscribing! ๐" }),
};
This is a smaller function because we did a lot of heavy lifting in the useEmail
function.
As long as the required fields are populated we shouldn't have any issues with the request going through.
The Flow
To round up this post and tie together all of the steps we've gone through, let's look at the flow of data:
Email Form Component
๐
UseForm -> For storing form info
๐
Email Form Component
๐
useEmail -> onSubmit send the info to the serverless function
๐
Serverless Function -> Submit to ConverKit
๐
Email Form Component -> Display the success message
There's a fair amount going on between several files but the flow isn't too complicated. Obviously, if stuff goes wrong then the flow can be short-circuited.
The 2 main places for a short-circuit to happen would be useEmail
and the serverless function.
Summing Up
I have been running a setup very similar to this on my website now for a few months and haven't had any issues. I like having all the functions separated into their own files as I do think it improves the readability.
The one thing we could add to improve this setup would be a Honeypot to capture any robots trying to fill in the form. But, I plan on covering this in a separate post where I can go more in-depth.
I didn't include any styling in this post for brevity, but if you're interested you can see it all on my GitHub here.
What do you think of this setup? Let me know over on Twitter.
I hope you found this post helpful. If you did please consider sharing it with others. If you would like to see more content like this please consider following me on Twitter.