Coner Murphy

Published on 1 May 2021 | 13 Minute Read

How to Create a Custom Email Signup Form for ConvertKit on GatsbyJS

If you have a newsletter on ConvertKit, you may want a custom signup form on your GatsbyJS website bespoke to your design. Here's how to set one up.

How to Create a Custom Email Signup Form for ConvertKit on GatsbyJS

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:

  1. The signup component users will interact with.
  2. A custom hook to handle the form changes.
  3. A custom hook to handle the submitting of the form.
  4. 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:

1export const EmailSignup = () => {
2 const { values, updateValue } = useForm({
3 email: "",
4 });
5 const { message, loading, error, submitEmail } = useEmail({ values });
6 const { email } = values;
7
8 return (
9 <>
10 <FormGridContainer onSubmit={submitEmail}>
11 <fieldset disabled={loading}>
12 <label htmlFor="email">
13 Email:
14 <input
15 type="email"
16 name="email"
17 id={`email-${Math.random().toString(36).substring(2, 15)}`}
18 className="emailInput"
19 onChange={updateValue}
20 value={email}
21 />
22 </label>
23 </fieldset>
24 <button className="signupButton" type="submit" disabled={loading}>
25 {loading ? "Subscribing..." : " Subscribe"}
26 </button>
27 </FormGridContainer>
28 {message ? (
29 <OutcomeMessageContainer error={error} message={message} />
30 ) : (
31 ""
32 )}
33 </>
34 );
35};
jsx

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:

1const OutcomeMessageContainer = ({ error, message }) => (
2 <MessageContainer>
3 {error ? <FaTimes data-error /> : <FaCheck />}
4 <p>{message}</p>
5 </MessageContainer>
6);
jsx

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

1import { useState } from "react";
2
3export default function useForm(defaults) {
4 const [values, setValues] = useState(defaults);
5
6 function updateValue(e) {
7 // Get value from the changed field using the event.
8 const { value } = e.target;
9
10 // Set the value by spreading in the existing values and chaging the key to the new value or adding it if not previously present.
11 setValues({
12 ...values,
13 [e.target.name]: value,
14 });
15 }
16
17 return { values, updateValue };
18}
jsx

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:

1{
2 email: "example@example.com";
3}
jsx

If we then look back at our original component where we consume this hook you can see how we use it:

1const { values, updateValue } = useForm({
2 email: "",
3});
4
5const { email } = values;
jsx

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:

1<input
2 type="email"
3 name="email"
4 id={`email-${Math.random().toString(36).substring(2, 15)}`}
5 className="emailInput"
6 onChange={updateValue}
7 value={email}
8/>
jsx

How does it know what index to update you may be asking?

Well, looking back at our `useForm` code, in the `updateValue` function:

1function updateValue(e) {
2 // Get value from the changed field using the event.
3 const { value } = e.target;
4
5 // Set the value by spreading in the existing values and chaging the key to the new value or adding it if not previously present.
6 setValues({
7 ...values,
8 [e.target.name]: value,
9 });
10}
jsx

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:

1import { useState } from "react";
2
3export default function useEmail({ values }) {
4 // Setting state to be returned depending on the outcome of the submission.
5 const [loading, setLoading] = useState(false);
6 const [message, setMessage] = useState("");
7 const [error, setError] = useState();
8
9 // destructuring out the values from values passed to this form.
10 const { email } = values;
11
12 const serverlessBase = process.env.GATSBY_SERVERLESS_BASE;
13
14 async function submitEmail(e) {
15 // Prevent default function of the form submit and set state to defaults for each new submit.
16 e.preventDefault();
17 setLoading(true);
18 setError(null);
19 setMessage(null);
20
21 // gathering data to be submitted to the serverless function
22 const body = {
23 email,
24 };
25
26 // Checking there was an email entered.
27 if (!email.length) {
28 setLoading(false);
29 setError(true);
30 setMessage("Oops! There was no email entered");
31 return;
32 }
33
34 // Send the data to the serverless function on submit.
35 const res = await fetch(`${serverlessBase}/emailSignup`, {
36 method: "POST",
37 headers: {
38 "Content-Type": "application/json",
39 },
40 body: JSON.stringify(body),
41 });
42
43 // Waiting for the output of the serverless function and storing into the serverlessBaseoutput var.
44 const output = JSON.parse(await res.text());
45
46 // check if successful or if was an error
47 if (res.status >= 400 && res.status < 600) {
48 // Oh no there was an error! Set to state to show user
49 setLoading(false);
50 setError(true);
51 setMessage(output.message);
52 } else {
53 // everyting worked successfully.
54 setLoading(false);
55 setMessage(output.message);
56 }
57 }
58
59 return {
60 error,
61 loading,
62 message,
63 submitEmail,
64 };
65}
jsx

It's a bit of a long one but it breaks down into logical chunks:

  1. We create some state with default values, destructure values from the props and get the serverless base from our `.env`
1export default function useEmail({ values }) {
2 // Setting state to be returned depending on the outcome of the submission.
3 const [loading, setLoading] = useState(false);
4 const [message, setMessage] = useState("");
5 const [error, setError] = useState();
6
7 // destructuring out the values from values passed to this form.
8 const { email } = values;
9
10 const serverlessBase = process.env.GATSBY_SERVERLESS_BASE;
11
12 // ... Rest of function
13}
jsx

At this point the function hasn't been called, so we create the state accordingly:

1loading -> false -> Not waiting on anything as not called
2message -> "" -> No info returned so blank by default
3error -> <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.)

1async function submitEmail(e) {
2 // Prevent default function of the form submit and set state to defaults for each new submit.
3 e.preventDefault();
4 setLoading(true);
5 setError(null);
6 setMessage(null);
7
8 // gathering data to be submitted to the serverless function
9 const body = {
10 email,
11 };
12
13 // Checking there was an email entered.
14 if (!email.length) {
15 setLoading(false);
16 setError(true);
17 setMessage("Oops! There was no email entered");
18 return;
19 }
20
21 // Send the data to the serverless function on submit.
22 const res = await fetch(`${serverlessBase}/emailSignup`, {
23 method: "POST",
24 headers: {
25 "Content-Type": "application/json",
26 },
27 body: JSON.stringify(body),
28 });
29
30 // Waiting for the output of the serverless function and storing into the output var.
31 const output = JSON.parse(await res.text());
32
33 // check if successful or if was an error
34 if (res.status >= 400 && res.status < 600) {
35 // Oh no there was an error! Set to state to show user
36 setLoading(false);
37 setError(true);
38 setMessage(output.message);
39 } else {
40 // everyting worked successfully.
41 setLoading(false);
42 setMessage(output.message);
43 }
44}
jsx

Once again, let's break this down into steps.

  1. 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.
1// Prevent default function of the form submit and set state to defaults for each new submit.
2e.preventDefault();
3setLoading(true);
4setError(null);
5setMessage(null);
jsx
  1. We create the body of the request we're going to submit by using the values from earlier.
1// gathering data to be submitted to the serverless function
2const body = {
3 email,
4};
jsx
  1. 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.
1// Checking there was an email entered.
2if (!email.length) {
3 setLoading(false);
4 setError(true);
5 setMessage("Oops! There was no email entered");
6 return;
7}
jsx
  1. If the email value is truthy, then we progress with the submission and do a `POST` request to the serverless function with the `body` we created.
1// Send the data to the serverless function on submit.
2const res = await fetch(`${serverlessBase}/emailSignup`, {
3 method: "POST",
4 headers: {
5 "Content-Type": "application/json",
6 },
7 body: JSON.stringify(body),
8});
jsx
  1. We await the reply from the serverless function, once received we convert it to text and parse it with `JSON.parse`.
1// Waiting for the output of the serverless function and storing into the output var.
2const output = JSON.parse(await res.text());
jsx
  1. 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.
1// check if successful or if was an error
2if (res.status >= 400 && res.status < 600) {
3 // Oh no there was an error! Set to state to show user
4 setLoading(false);
5 setError(true);
6 setMessage(output.message);
7} else {
8 // everyting worked successfully.
9 setLoading(false);
10 setMessage(output.message);
11}
jsx

Returning data

After we have processed the request, we return the below info back out of the helper function:

1return {
2 error,
3 loading,
4 message,
5 submitEmail,
6};
jsx

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:

1const { message, loading, error, submitEmail } = useEmail({ values });
jsx

We consume the destructured values in the following places:

  1. `onSubmit` function for the form
1<FormGridContainer onSubmit={submitEmail}>
jsx
  1. Disabling the submit button if loading is true and changing the text within it.
1<button className="signupButton" type="submit" disabled={loading}>
2 {loading ? "Subscribing..." : " Subscribe"}
3</button>
jsx
  1. We use the display component from earlier to show the message to the user and whether there has been an error or not.
1{
2 message ? <OutcomeMessageContainer error={error} message={message} /> : "";
3}
jsx

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:

1require("isomorphic-fetch");
2
3exports.handler = async (event) => {
4 const body = JSON.parse(event.body);
5
6 // Checking we have data from the email input
7 const requiredFields = ["email"];
8
9 for (const field of requiredFields) {
10 if (!body[field]) {
11 return {
12 statusCode: 400,
13 body: JSON.stringify({
14 message: `Oops! You are missing the ${field} field, please fill it in and retry.`,
15 }),
16 };
17 }
18 }
19
20 // Setting vars for posting to API
21 const endpoint = "https://api.convertkit.com/v3/forms/";
22 const APIKey = process.env.CONVERTKIT_PUBLIC_KEY;
23 const formID = process.env.CONVERTKIT_SIGNUP_FORM;
24
25 // posting to the Convertkit API
26 await fetch(`${endpoint}${formID}/subscribe`, {
27 method: "post",
28 body: JSON.stringify({
29 email: body.email,
30 api_key: APIKey,
31 }),
32 headers: {
33 "Content-Type": "application/json",
34 charset: "utf-8",
35 },
36 });
37 return {
38 statusCode: 200,
39 body: JSON.stringify({ message: "Success! Thank you for subscribing! šŸ˜ƒ" }),
40 };
41};
jsx

Let's walk through this again as we did with the other functions:

  1. Parse the JSON body that was sent from `useEmail`.
1const body = JSON.parse(event.body);
jsx
  1. Check we have filled the required fields, if not return an error saying they were missed.
1// Checking we have data from the email input
2const requiredFields = ["email"];
3
4for (const field of requiredFields) {
5 if (!body[field]) {
6 return {
7 statusCode: 400,
8 body: JSON.stringify({
9 message: `Oops! You are missing the ${field} field, please fill it in and retry.`,
10 }),
11 };
12 }
13}
jsx
  1. Get our variables from `.env` and then submit a `POST` request to ConvertKit.
1// Setting vars for posting to API
2const endpoint = process.env.CONVERTKIT_ENDPOINT;
3const APIKey = process.env.CONVERTKIT_PUBLIC_KEY;
4const formID = process.env.CONVERTKIT_SIGNUP_FORM;
5
6// posting to the Convertkit API
7await fetch(`${endpoint}${formID}/subscribe`, {
8 method: "post",
9 body: JSON.stringify({
10 email: body.email,
11 api_key: APIKey,
12 }),
13 headers: {
14 "Content-Type": "application/json",
15 charset: "utf-8",
16 },
17});
jsx
  1. Process the return
1return {
2 statusCode: 200,
3 body: JSON.stringify({ message: "Success! Thank you for subscribing! šŸ˜ƒ" }),
4};
jsx

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:

1Email Form Component
2šŸ‘‡
3UseForm -> For storing form info
4šŸ‘‡
5Email Form Component
6šŸ‘‡
7useEmail -> onSubmit send the info to the serverless function
8šŸ‘‡
9Serverless Function -> Submit to ConverKit
10šŸ‘‡
11Email 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.



šŸ‘„

Latest Content...

What Iā€™m up to and more.

  • How to make a flicker-free dark theme toggle with Next.js, TailwindCSS, and `next-themes`

    7 Jun 2022

    How to make a flicker-free dark theme toggle with Next.js, TailwindCSS, and `next-themes`

    Here's how to build a dark/light theme toggle in Next.js using TailwindCSS and the next-themes npm package to make it load and transition flicker-free.

  • 3 Reasons Why You Need to Design Components and Not Pages

    26 Jun 2021

    3 Reasons Why You Need to Design Components and Not Pages

    If you want to design better websites then you need to stop designing at a page level and start thinkning about components. Here's 3 reasons why.

  • 6 Twitter Analytics Terms You Need to Know to Grow an Audience in 2021

    26 Jun 2021

    6 Twitter Analytics Terms You Need to Know to Grow an Audience in 2021

    Building a Twitter audience is a great way to grow your business. So, here are 6 Twitter analytics terms you need to know and how to measure them.

Want to read more?

View all posts here