Kick-Start Your Newsletter: Mailchimp Custom Form with React

cover image

Recently, I had to create a subscription form for my blog/portfolio website. I decided to use Mailchimp because of its popularity and how highly efficient it is at handling subscribers. They have many form templates for getting started quickly. However, these templates handle the requests directly on the form's HTML instead of through a Front-End library like React. This is great for a simple website that does not require to different states or interactions. Nonetheless, For my site, I wanted to use React to create a custom experience for my users. Through various attempts, I was able to find a testable and efficient way to handle these forms to React without sacrificing interactivity.

In this article, I will explain how we could implement a custom Mailchimp form with React using JSONP or serverless function.

Serverless Functions or JSONP

You may be asking what should you choose between JSONP and serverless. My recommendation would be to go with serverless since it is more flexible, secure, and simpler to test. However, it requires you to set up a serverless provider like Amazon AWS, Vercel, Netlify, etc which is not always possible in certain situations. On the other hand, JSONP is really easy to get started, and only React with some dependencies is needed.

In this post, I'm going to implement it first with JSONP, and then with serverless.

Getting Your Mailchimp's Generated Form URL

Before we can start, we need to get the URL for sending a subscription request. Since we are going to be doing both a JSONP and serverless, we will have to get the URL of the solution we need.

Getting JSONP URL

To get the URL, head to Signup Forms under Audience in your admin account dashboard. ****

Select Signup forms

Once there, select Embedded Forms.

Select embedded forms

Under Copy/paste onto your site, look for the URL inside the action attribute in the form. This is the URL we will be using.

Arrow pointing to action attribute

That's it, nice job! Now, you can start creating your JSONP hook.

Creating a Hook For Subscribing Through JSONP

The reason why we are going to use JSON with Padding (JSONP) instead of fetch/Axios is that if we try to do a POST request to Mailchimp servers we will get a CORS error. This happens because by default the fetch/Axios API doesn't allow creating requests to different origins unless the response from the other domain includes the right CORS headers. Unfortunately, Mailchimp's will never send the right CORS header for security reasons. JSONP is one interesting technique that allows us to make a GET request to servers of different origins. It basically allows us to bypass the CORS policy.

The first step for creating our hook is to install the jsonp package:

npm install jsonp
// or
yarn add jsonp

First, we declared a Status object which contains all of the possible states of our request, so we can determine the state in our form in a predictable way.

export const Status = {
	idle: 'IDLE',
	loading: 'LOADING',
	success: 'SUCCESS',	
	error: 'ERROR'
}

Second, we declare a toQueryString function which converts an object to a query string to be added as URL parameters.

/*

	Converts {foo: 'bar', key: 'bar2'} to 'foo=bar&key=bar2'

*/

function toQueryString(params) {
  return Object.keys(params)
    .map((key) => key + "=" + params[key])
    .join("&");
}

Third, inside our hook, we declare two React state variables, one for the error output and the other for the value received.

const [status, setStatus] = React.useState(Status.idle);
const [error, setError] = React.useState(null);
const [value, setValue] = React.useState(null);

Finally, we have a subscription method that will receive the data we need for the user to subscribe. This subscribe method first converts all our data into a query string and appends it to a new URL that replaces the post with post-json resource. Then we proceed to do the request while setting the status and error as needed.

const subscribe = React.useCallback((data) => {
    const params = toQueryString(data);
    const ajaxURL = url.replace("/post?", "/post-json?");
    const newUrl = ajaxURL + "&" + params;

    setError(null);
    setStatus(Status.loading);

    jsonp(newUrl, { param: "c" }, (err, data) => {
      if (err) {
        setStatus(Status.error);
        setError(err);
      } else if (data.result !== "success") {
        setStatus(Status.error);
        setError(data.msg);
      } else {
        setStatus(Status.success);
        setValue(data.msg);
      }
    });
  }, []);

Putting all these together:

export const Status = {
	idle: 'IDLE',
	loading: 'LOADING',
	success: 'SUCCESS',	
	error: 'ERROR'
}

function toQueryString(params) {
  return Object.keys(params)
    .map((key) => key + "=" + params[key])
    .join("&");
}

export function useMailChimp(url) {
  const [status, setStatus] = React.useState(Status.idle);
  const [error, setError] = React.useState(null);
  const [value, setValue] = React.useState(null);

  const subscribe = React.useCallback((data) => {
    const params = toQueryString(data);
    const ajaxURL = url.replace("/post?", "/post-json?");
    const newUrl = ajaxURL + "&" + params;

    setError(null);
    setStatus(Status.loading);

    jsonp(newUrl, { param: "c" }, (err, data) => {
      if (err) {
        setStatus(Status.error);
        setError(err);
      } else if (data.result !== "success") {
        setStatus(Status.error);
        setError(data.msg);
      } else {
        setStatus(Status.success);
        setValue(data.msg);
      }
    });
  }, []);

  return { subscribe, status, error, value };
}

That's it! Our hook is ready to be used in our app. Here is an example of how you could use it in your app.

import React from 'react'
import {useMailchimp, Status} from 'hooks'

const SubscribeForm = () => {
  const [form, setForm] = React.useState({firstName: '', email: ''})
  const {subscribe, status, error, value} = useMailchimp('YOUR_MAILCHIMP_URL')

  const handleInputChange = event => {
    const target = event.target
    const name = target.name
    const value = target.value

    setForm(form => ({
      ...form,
      [name]: value,
    }))
  }

  const handleSubmit = () => {
    // We are performing some simple validation here.
    // I highly recommend checking a fully-fledged solution
    // for forms like https://react-hook-form.com/
    if (form.firstName === '' || form.email === '') return

    subscribe({
      FNAME: form.firstName,
      EMAIL: form.email,
    })
  }

  if (status === Status.loading) {
    return <Loading />
  }

  if (status === Status.error) {
    return <Error error={error}/>
  }

  if (value.includes('Already subscribed')) {
    return <Success subscribed />
  }

  if (value) {
    return <Success />
  }

  return (
    <form>
      <label htmlFor="firstName">First Name</label>
      <input
        id="firstName"
        value={form.firstName}
        onChange={handleInputChange}
      />

      <label htmlFor="email">Email</label>
      <input id="email" value={form.email} onChange={handleInputChange} />

      <button onClick={handleSubmit}>Subscribe 📨</button>
    </form>
  )
}

Some of the main available names for your data keys are EMAIL, FNAME (first name), and LNAME(last name). You can find others on Mailchimp's website.

Getting the Serverless URL and API key

To get a URL available for a Serverless integration we need two things:

  1. The server your Mailchimp account is located.
  2. Your Audience List ID.

Mailchimp's API route is as follows:

https://SERVER.api.mailchimp.com/3.0/lists/LIST_ID/members

To get your server, you just have to check the subdomain you use to access and check your account.

Arrow pointing to url which contains the server

For your Audience List ID, click Audience and then All contacts.

Select all contacts

Click your desired audience. Click the Settings drop-down and choose Audience name and defaults.

Select audience name and defaults

In the Audience ID section, you’ll see a string of letters and numbers. This is your audience ID.

Once you have both of these, substitute them in the URL:

https://SERVER.api.mailchimp.com/3.0/lists/LIST_ID/members

For the API key please refer to Mailchimp's docs where there's a quick and beautiful tutorial.

Creating a Serverless Function for Subscribing Users

The serverless function integration depends on your provider. However, they will look very similar to each other. That said, I will be creating the function with Next.js API Routes.

I will ask for the same properties as I did for JSONP which are the user's first name and email. Therefore, I will have an EMAIL and FNAME field. For other available properties please refer to Mailchimp's website.

Our first step is to create the function:

import { NextApiRequest, NextApiResponse } from "next";

export default async (req, res) => {
 if (req.method === "POST") {
  } else {
    res.setHeader("Allow", ["POST"]);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
};

Then, let's add some validation with an amazingly simple validation library called ow. It will prevent faulty provided values in the request.

Let's add the package:

npm install ow
// or
yarn add ow

And in our function add some basic validation for our API key, email and first name:

import ow from "ow";
import { NextApiRequest, NextApiResponse } from "next";

function owWithMessage(val, message, validator) {
  try {
    ow(val, validator);
  } catch (error) {
    throw new Error(message);
  }
}

owWithMessage(
  process.env.MAILCHIMP_API_KEY,
  "MAILCHIMP_API_KEY environment variable is not set",
  ow.string.minLength(1)
);

const isEmail = ow.string.is((e) => /^.+@.+\..+$/.test(e));

export default async (req, res) => {
	try {
    console.log("> Validating input", " name: ", firstName, " email:", email);
    owWithMessage(firstName, "the name is too long", ow.string.maxLength(120));
    owWithMessage(
      email,
      "The email is invalid. Please enter a valid email address.",
      isEmail
    );
  } catch (e) {
    console.log("> Validation failed", e.message);
    res.status(403).json({ message: e.message });
  }

 if (req.method === "POST") {
  } else {
    res.setHeader("Allow", ["POST"]);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
};

To perform requests to Mailchimp's servers we need an API key. If you did not follow the instructions above, you can refer to Mailchimp's docs on how to get this key.

We will be declaring our authorization string with the generated API KEY which will be stored in an environment variable.

const authorization =
  "Basic " +
  Buffer.from("randomstring:" + process.env.MAILCHIMP_API_KEY).toString(
    "base64"
  );

Now, what's left is handling the request to Mailchimp's servers. I will be using the fetch API since Next.js now is able to use its server-side. For other providers, it is best to use libraries like node-fetch or Axios.

Mailchimp has an interesting way of handling subscribers. Once a user has been subscribed, he cannot use the subscribe endpoint again if he were to unsubscribe and would want to subscribe again. To solve this, one has to use a PATCH request method instead of a POST. Therefore, we have to verify the status of the contact before selecting a request method.

To check and update the status of a subscriber or member, Mailchimp requires for it to be included on the URL as an md5 hash. For this, we will be using a library called blueimp-md5.

Let's translate all this to code. We will have three mini services: checkContactStatus, addNewMember, and subscribeContact.

import md5 from "blueimp-md5";

function addNewMember(email: string, firstName: string) {
  return fetch("https://SERVER.api.mailchimp.com/3.0/lists/LIST_ID/members", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization,
    },
    body: JSON.stringify({
      email_address: email,
      status: "subscribed",
      merge_fields: {
        FNAME: firstName,
      },
    }),
  });
}

function subscribeContact(email: string) {
  return fetch(
    `https://SERVER.api.mailchimp.com/3.0/lists/LIST_ID/members/${md5(
      email.toLowerCase()
    )}`,
    {
      method: "PATCH",
      body: JSON.stringify({
        status: "subscribed",
      }),
      headers: {
        authorization,
      },
    }
  );
}

function checkContactStatus(email: string) {
  return fetch(
    `https://SERVER.api.mailchimp.com/3.0/lists/LIST_ID/members/${md5(
      email.toLowerCase()
    )}`,
    {
      headers: {
        authorization,
      },
    }
  );
}

Let's connect these services in our function:

if (req.method === "POST") {
    try {
      const { status } = await (await checkContactStatus(email)).json();
			
			// Add the contact is there is no member associated with the given email.
      if (status === 404) {
        const message = await (await addNewMember(email, firstName)).json();
			
				// Return a Not Acceptable code when Mailchimp detects 
				// a spam email or invalid property
        if (message.status === 400) {
          message.status = 406;
          return res.status(406).json({ message });
        }

        if (message) {
          return res.status(200).json({ message });
        }
      } else if (status === "subscribed") {
        res.status(400).json({ message: "User already subscribed" });
      } else {
				// Resubcribe the user
        const message = await (await subscribeContact(email)).json();
        if (message) {
          res.status(200).json({ message });
        }
      }
    } catch (err) {
      console.log(err);
      res.status(500).json({ message: err });
    }
  } else {
    res.setHeader("Allow", ["POST"]);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }

Now we can handle the edge cases of subscribing to our users. It is so much easier to test than using JSONP since we can stub the network calls.

Here is the complete function:

import { NextApiRequest, NextApiResponse } from "next";
import ow from "ow";
import md5 from "blueimp-md5";

function owWithMessage(val, message, validator) {
  try {
    ow(val, validator);
  } catch (error) {
    throw new Error(message);
  }
}

owWithMessage(
  process.env.MAILCHIMP_API_KEY,
  "MAILCHIMP_API_KEY environment variable is not set",
  ow.string.minLength(1)
);

const isEmail = ow.string.is((e) => /^.+@.+\..+$/.test(e));
const authorization =
  "Basic " +
  Buffer.from("randomstring:" + process.env.MAILCHIMP_API_KEY).toString(
    "base64"
  );

function addNewMember(email: string, firstName: string) {
  return fetch("https://SERVER.api.mailchimp.com/3.0/lists/LIST_ID/members", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization,
    },
    body: JSON.stringify({
      email_address: email,
      status: "subscribed",
      merge_fields: {
        FNAME: firstName,
      },
    }),
  });
}

function subscribeContact(email: string) {
  return fetch(
    `https://SERVER.api.mailchimp.com/3.0/lists/LIST_ID/members/${md5(
      email.toLowerCase()
    )}`,
    {
      method: "PATCH",
      body: JSON.stringify({
        status: "subscribed",
      }),
      headers: {
        authorization,
      },
    }
  );
}

function checkContactStatus(email: string) {
  return fetch(
    `https://SERVER.api.mailchimp.com/3.0/lists/LIST_ID/members/${md5(
      email.toLowerCase()
    )}`,
    {
      headers: {
        authorization,
      },
    }
  );
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
  const { firstName, email } = req.body;

  try {
    console.log("> Validating input", " name: ", firstName, " email:", email);
    owWithMessage(firstName, "the name is too long", ow.string.maxLength(120));
    owWithMessage(
      email,
      "The email is invalid. Please enter a valid email address.",
      isEmail
    );
  } catch (e) {
    console.log("> Validation failed", e.message);
    res.status(403).json({ message: e.message });
  }

  if (req.method === "POST") {
    try {
      const { status } = await (await checkContactStatus(email)).json();

      if (status === 404) {
        const message = await (await addNewMember(email, firstName)).json();

        if (message.status === 400) {
          message.status = 406;
          return res.status(406).json({ message });
        }

        if (message) {
          return res.status(200).json({ message });
        }
      } else if (status === "subscribed") {
        res.status(400).json({ message: "User already subscribed" });
      } else {
        const message = await (await subscribeContact(email)).json();
        if (message) {
          res.status(200).json({ message });
        }
      }
    } catch (err) {
      console.log(err);
      res.status(500).json({ message: err });
    }
  } else {
    res.setHeader("Allow", ["POST"]);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
};

That's it! In our components, we can request our function and act accordingly to the result. For reference, here are the possible subscription states according to their HTTP status codes:

  • 200 - Successfully added subscriber or resubscribed.
  • 400 - User is already subscribed.
  • 403 - User has sent invalid credentials to function.
  • 406 - User has sent spam email or credentials that are invalid on Mailchimp
  • 500 - Function has encountered an unexpected error.

Here is a quick example:

import React from "react";
import axios from "axios";

export const Status = {
  idle: "IDLE",
  loading: "LOADING",
  success: "SUCCESS",
  error: "ERROR",
};

async function subscribe({ firstName, email }) {
  const response = await axios.post("/api/subscribe", {
    firstName,
    email,
  });
  return response;
}

const SubscribeForm = () => {
  const [form, setForm] = React.useState({ firstName: "", email: "" });
  const [subscribeState, setSubscribeState] = React.useState({
    status: Status.idle,
    value: "",
    error: null,
  });

  const handleInputChange = (event) => {
    const target = event.target;
    const name = target.name;
    const value = target.value;

    setForm((form) => ({
      ...form,
      [name]: value,
    }));
  };

  const handleSubmit = () => {
    // We are performing some simple validation here.
    // I highly recommend checking a fully-fledged solution
    // for forms like https://react-hook-form.com/
    if (form.firstName === "" || form.email === "") return;

    subscribe({ firstName: "", email: "" }).then((res) => {
      switch (res.status) {
        case 200:
          setSubscribeState({
            status: Status.success,
            value: res.data,
            error: null,
          });
          break;
        case 400:
          setSubscribeState({
            status: Status.success,
            value: "Already Subscribed",
            error: null,
          });
          break;
        case 403:
        case 406:
        case 500:
          setSubscribeState({
            status: Status.error,
            value: "",
            error: res.data,
          });
          break;
      }
    });
  };

  if (subscribeState.status === Status.loading) {
    return <Loading />;
  }

  if (subscribeState.status === Status.error) {
    return <Error error={error} statusCode={subscribeState.status} />;
  }

  if (subscribeState.value.includes("Already subscribed")) {
    return <Success subscribed />;
  }

  if (subscribeState.value) {
    return <Success />;
  }

  return (
    <form>
      <label htmlFor="firstName">First Name</label>
      <input
        id="firstName"
        value={form.firstName}
        onChange={handleInputChange}
      />

      <label htmlFor="email">Email</label>
      <input id="email" value={form.email} onChange={handleInputChange} />

      <button onClick={handleSubmit}>Subscribe 📨</button>
    </form>
  );
};

Conclusion

Mailchimp is very popular and amazing to manage your subscribers or audience. Creating a custom form in a Front-End library like React can be daunting, especially since a normal server request to Mailchimp's servers using fetch or Axios won't work. With JSONP or a serverless function, one can create an amazing experience not bound to template forms.

Choosing between JSONP and serverless function depends on how robust the implementation has to be. JSONP is best for a quick solution that requires minimal code and no serverless providers. On the other hand, a serverless function allows for a testable, robust, and safer solution.

For more up-to-date web development content, follow me on Twitter, and Dev.to! Thanks for reading! 😎


Front-End novelty in your inbox

My goal is to create easy-to-understand, rich blog posts for Front-End Developers. If you love to learn more about streamlined front-end practices, you'll more likely love what I'm working on! Don't worry, there will not be spam, and you can unsubscribe at any time.

You will also receive awesome weekly resources to stay ahead in web development!

(required)
contactContact Me