Recently I added a signup form for my email list to the site. Using ConvertKit's API, Tailwind CSS, and AlpineJS and the Javascript Fetch API made it easy to whip up. The result is a form that's much more customizable than the embeddable forms provided by ConvertKit. This article breaks down how I did it, and how you can too.
Our first order of business is to set up the form. You'll need to replace YOUR_FORM_ID
with the ID of the form (within ConvertKit) that you want to capture emails for. I've left out most styling so it's easier to parse the example, but you can always inspect the code on my site to see which tailwind classes (and transitions) I'm using.
The Signup Form
1<div x-data="emailSignup()" x-init="trackFormVisit()">2 <div x-cloak x-show="error" x-text="error"></div>3 <div x-cloak x-show="success" x-text="success"></div>4 <form5 x-ref="signupForm"6 action="https://api.convertkit.com/v3/forms/YOUR_FORM_ID/subscribe"7 method="POST"8 >9 <div>10 <input x-model="email" name="email" placeholder="Your Email Address" />11 <button12 @click.prevent="submit()"13 :class="{ 'opacity-50': working }"14 :disabled="working"15 >16 <!-- Loading spinner in the button (optional) -->17 <span x-cloak x-show="working"18 ><svg19 class="inline-block animate-spin -ml-1 mr-3 h-5 w-5 text-white"20 xmlns="http://www.w3.org/2000/svg"21 fill="none"22 viewBox="0 0 24 24"23 >24 <circle25 class="opacity-25"26 cx="12"27 cy="12"28 r="10"29 stroke="currentColor"30 stroke-width="4"31 ></circle>32 <path33 class="opacity-75"34 fill="currentColor"35 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"36 ></path>37 </svg>38 Working...</span39 >40 <span x-show="!working">Subscribe</span>41 </button>42 </div>43 </form>44</div>
1<div x-data="emailSignup()" x-init="trackFormVisit()">2 <div x-cloak x-show="error" x-text="error"></div>3 <div x-cloak x-show="success" x-text="success"></div>4 <form5 x-ref="signupForm"6 action="https://api.convertkit.com/v3/forms/YOUR_FORM_ID/subscribe"7 method="POST"8 >9 <div>10 <input x-model="email" name="email" placeholder="Your Email Address" />11 <button12 @click.prevent="submit()"13 :class="{ 'opacity-50': working }"14 :disabled="working"15 >16 <!-- Loading spinner in the button (optional) -->17 <span x-cloak x-show="working"18 ><svg19 class="inline-block animate-spin -ml-1 mr-3 h-5 w-5 text-white"20 xmlns="http://www.w3.org/2000/svg"21 fill="none"22 viewBox="0 0 24 24"23 >24 <circle25 class="opacity-25"26 cx="12"27 cy="12"28 r="10"29 stroke="currentColor"30 stroke-width="4"31 ></circle>32 <path33 class="opacity-75"34 fill="currentColor"35 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"36 ></path>37 </svg>38 Working...</span39 >40 <span x-show="!working">Subscribe</span>41 </button>42 </div>43 </form>44</div>
Now that the form is in place, we need to hook it up to the ConvertKit API. At this point we'll need the API key, which you can find in your account section in ConvertKit. Just replace YOUR_API_KEY
in the code with the correct key. Although it will work, do not use the api_secret
here, because we'll be exposing it in client side (Javascript) code.
Instead of revealing this code piece by piece, the main explanations are in the comments. Take a read through, and I'll summarize again after.
1function emailSignup() {2 return {3 // Our "data" -- the email field, messages, and form state4 email: null,5 error: false,6 success: false,7 working: false,89 // When the form is submitted10 submit() {11 // Specify that the form is working (for the loading state)12 this.working = true1314 // Using the Fetch API, make a JSON POST request to ConvertKit15 fetch(this.$refs.signupForm.action, {16 method: 'POST',17 headers: {18 Accept: 'application/json',19 'Content-Type': 'application/json',20 },21 body: JSON.stringify({22 api_key: 'YOUR_API_KEY',23 email: this.email,24 }),25 })26 // The response of fetch is a stream object, so two .then()27 // functions are needed here.28 // See here: https://developers.google.com/web/updates/2015/03/introduction-to-fetch?hl=en29 .then((response) => response.json())30 .then((json) => {31 // If there's an error, display the message32 if (json.error) {33 this.error = json.message34 this.success = false35 } else {36 // No errors, so we'll display our success message or37 // a "you've already subscribed" message if that's the case.38 this.error = false39 if (json.subscription.state == 'active') {40 this.success = 'Looks like you are already subscribed, thank you!'41 } else {42 this.success =43 'Success! Please check your email to confirm your subscription.'44 }45 }46 })47 .finally(() => {48 // Hide the loading state49 this.working = false50 })51 },52 // This allows the analytics for the form to work as if it53 // were a normal form embed from ConvertKit.54 // It is called via x-init in the Alpine component55 trackFormVisit() {56 fetch('https://app.convertkit.com/forms/1570815/visit', {57 method: 'POST',58 headers: {59 Accept: 'application/json',60 'Content-Type': 'application/json',61 },62 })63 },64 }65}
1function emailSignup() {2 return {3 // Our "data" -- the email field, messages, and form state4 email: null,5 error: false,6 success: false,7 working: false,89 // When the form is submitted10 submit() {11 // Specify that the form is working (for the loading state)12 this.working = true1314 // Using the Fetch API, make a JSON POST request to ConvertKit15 fetch(this.$refs.signupForm.action, {16 method: 'POST',17 headers: {18 Accept: 'application/json',19 'Content-Type': 'application/json',20 },21 body: JSON.stringify({22 api_key: 'YOUR_API_KEY',23 email: this.email,24 }),25 })26 // The response of fetch is a stream object, so two .then()27 // functions are needed here.28 // See here: https://developers.google.com/web/updates/2015/03/introduction-to-fetch?hl=en29 .then((response) => response.json())30 .then((json) => {31 // If there's an error, display the message32 if (json.error) {33 this.error = json.message34 this.success = false35 } else {36 // No errors, so we'll display our success message or37 // a "you've already subscribed" message if that's the case.38 this.error = false39 if (json.subscription.state == 'active') {40 this.success = 'Looks like you are already subscribed, thank you!'41 } else {42 this.success =43 'Success! Please check your email to confirm your subscription.'44 }45 }46 })47 .finally(() => {48 // Hide the loading state49 this.working = false50 })51 },52 // This allows the analytics for the form to work as if it53 // were a normal form embed from ConvertKit.54 // It is called via x-init in the Alpine component55 trackFormVisit() {56 fetch('https://app.convertkit.com/forms/1570815/visit', {57 method: 'POST',58 headers: {59 Accept: 'application/json',60 'Content-Type': 'application/json',61 },62 })63 },64 }65}
When the Alpine component initializes (x-init
), we track that the signup form has been visited. This makes the form play nice with ConvertKit's built in form analytics. When the submit button is clicked, the submit()
function runs. From that point, it's a pretty straightforward AJAX request using the Javascript Fetch API. It shows errors if the ConvertKit API returns them, otherwise it shows a success message. That's all there is to it!
If you want to take it a step further, you can package all of this up in a blade component (or similar templating system) and it becomes a nice, reusable email signup form!