DEV Community

Cover image for How to Create a Custom Email Signup Form for ConvertKit on GatsbyJS.
Coner Murphy
Coner Murphy

Posted on • Originally published at conermurphy.com on

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:

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

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

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

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

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

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

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

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

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

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

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

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.
// Prevent default function of the form submit and set state to defaults for each new submit.
e.preventDefault();
setLoading(true);
setError(null);
setMessage(null);
Enter fullscreen mode Exit fullscreen mode
  1. 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,
};
Enter fullscreen mode Exit fullscreen mode
  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.
// Checking there was an email entered.
if (!email.length) {
  setLoading(false);
  setError(true);
  setMessage("Oops! There was no email entered");
  return;
}
Enter fullscreen mode Exit fullscreen mode
  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.
// 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),
});
Enter fullscreen mode Exit fullscreen mode
  1. 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());
Enter fullscreen mode Exit fullscreen mode
  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.
// 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);
}
Enter fullscreen mode Exit fullscreen mode

Returning data

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

return {
  error,
  loading,
  message,
  submitEmail,
};
Enter fullscreen mode Exit fullscreen mode

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

We consume the destructured values in the following places:

  1. onSubmit function for the form
<FormGridContainer onSubmit={submitEmail}>
Enter fullscreen mode Exit fullscreen mode
  1. 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>
Enter fullscreen mode Exit fullscreen mode
  1. 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} /> : "";
}
Enter fullscreen mode Exit fullscreen mode

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

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

  1. Parse the JSON body that was sent from useEmail.
const body = JSON.parse(event.body);
Enter fullscreen mode Exit fullscreen mode
  1. 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.`,
      }),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Get our variables from .env and then submit a POST 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",
  },
});
Enter fullscreen mode Exit fullscreen mode
  1. Process the return
return {
  statusCode: 200,
  body: JSON.stringify({ message: "Success! Thank you for subscribing! 😃" }),
};
Enter fullscreen mode Exit fullscreen mode

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

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.

Top comments (0)