Newsletter Subscriptions with NextJS & ConvertKit

Newsletter Subscriptions with NextJS & ConvertKit

Newsletter subscriptions are a mixed bag for me. On the one hand, from a marketing perspective, your subscriber list (that you own, and not FB or X) is probably your most valuable asset.

On the other hand, writing newsletters is a pain.

My goal here, I think, is to put in place something EXTREMELY automated... something along the lines of:

  • Write an MDX blog post
  • Commit to Git
  • Vercel auto-deploys (already happens)
  • Git hook fires to an API route
  • A newsletter is drafted and sent to my contacts

That would be nice. We'll see if I can get there eventually. But for today, I just need a way to let subscribers register from the site.

Email Platforms
Full disclosure: This is the first time I'm trying ConvertKit. I'm not an email marketing expert, but I have tried quite a few different platforms. In the past I have used SendGrid, MailChimp, Brevo, PostMark, and a handful of others. So far I haven't yet found a platform that fits 100% of my specific needs, which are:

  • Good API (bonus points for SDK)
  • Can send plain text emails
  • Easy, robust sequences
  • Cost effective at low volume
  • Paid & Free newsletter options

  • ConvertKit seems to get close, which is why I'm trying it out. If you have a recommendation, please share it on Twitter

    So with that out of the way, let's get started! ⚡️

    Create a ConvertKit Account

    First things first, head over and create an account at ConvertKit.

    Set up a contact group

    Once you've created an account, you're going to want to create a tag to bucket users that register from your site.

    So go to Grow > Subscribers from the nav bar...

    convertkit-shot-1

    Then at the bottom right, click + Create a Tag (it's small and hard to find... ahem 😳).

    convertkit-shot-2

    Add a NextJS /api route

    Now, we want to create a NextJS /api route that adds a subscriber to our list, with the new tag we have created.

    Head over to the ConvertKit api docs.

    You can see from the API docs that the call we want looks like this:

    # Include a tag during subscribing
    curl -X POST https://api.convertkit.com/v3/forms/<form_id>/subscribe\
         -H "Content-Type: application/json; charset=utf-8"\
         -d '{ \
               "api_key": "<your_public_api_key>",\
               "email": "jonsnow@example.com",\
               "tags": [1234, 5678]\
             }'
    

    So we'll need a few things to get this going:

    1.  <form_id>
    2.  <your_public_api_key>
    3.  [1234,5678]  # our tag ids
    

    First, let's find the Form ID...

    Now, to get your public_api_key, just head over to your ConvertKit account settings.

    In the Advanced section, you'll find your API key.

    Lastly, we need to find our Tag IDs, which is similar to finding out our form id.

    We are going to want to set both of these as environment variables in our NextJS app. So let's create a file called .env.local and paste in the following:

    CONVERTKIT_PUBLIC_API_KEY=your_public_api_key   # Be sure and make these the actual values you got above
    CONVERTKIT_SUBSCRIBE_FORM_ID=form_id            # Be sure and make these the actual values you got above
    
    Note
    You'll need to restart your dev server for these changes to take effect.

    Now, we're ready to make our API route.

    Create a new file at /api/newsletter-subscribe, and add the following code:

    /api/newsletter-subscribe.js
    // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
    export default async function handler(req, res) {
      const { email } = req.body
    
      if (!email) {
        // Tell developers when they are missing params
        res.status(400).json({ message: 'No email address provided.' });
        return;
      }
    
      const api_key = process.env.CONVERTKIT_PUBLIC_API_KEY;
      const formId = process.env.CONVERTKIT_SUBSCRIBE_FORM_ID;
    
      if (!api_key || !formId) {
        // Don't tell people about internal server errors
        res.status(500);
        return;
      }
    
      const url = `https://api.convertkit.com/v3/forms/${formId}/subscribe`;
      const tags = [4062331];
    
      try {
        const body = JSON.stringify({ api_key, email, tags })
    
        // POST a request to the ConvertKit endpoint
        await fetch(url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json; charset=utf-8'
          },
          body,
          redirect: 'follow'
        });
    
        // Send a non-descriptive success response
        res.status(200).json({ success: true });
      } catch(e) {
        // Don't tell people about internal server errors
        res.status(500);
      }
    }
    
    

    Now, let's see if our API route works using a cURL request from our command line.

    curl --location --request POST 'http://localhost:3000/api/newsletter-subscribe' \
    --header 'Content-Type: application/json' \
    --data-raw '{
        "email": "[youremail]+newsletter@gmail.com"
    }'
    
    Real Email
    ConvertKit sends a confirmation email that you have to click to actually subscribe, so be sure to send to a real email address.

    I tend to just at a +newsletter to my normal Gmail account, like ty+newsletter@[yourdomain].com.

    If everything works, you should get a line back like this:

    {"success":true}
    

    Once you've confirmed your subscription, go back to your ConvertKit Subscribers, and you should see test@example.com in your subscribers list!

    On security...
    From the NextJS docs...

    API Routes do not specify CORS headers, meaning they are same-origin only by default. You can customize such behavior by wrapping the request handler with the CORS request helpers.

    So where we would typically need to add some sort of security, Next takes care of this for us.

    Now, alls we need is a form. 🛠

    Create the Subscribe Form

    I'm gonna burn through this with a couple of big code blocks, so if you're new to forms be sure to check out my post on quickly building forms with NextJS, React Hook Form, and TailwindCSS.

    Create a new file at /components/Newsletter.jsx and paste the following code:

    /components/Newsletter.jsx
    import { useState } from 'react'
    import { useForm } from 'react-hook-form'
    import * as yup from 'yup'
    import { yupResolver } from '@hookform/resolvers/yup'
    
    import { Button } from '@/components/Button'
    
    const schema = yup.object().shape({
      email: yup
        .string()
        .email('Please enter a valid email.')
        .required('Email address is required.'),
    })
    
    function MailIcon(props) {
      return (
        <svg
          viewBox="0 0 24 24"
          fill="none"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
          {...props}
        >
          <path
            d="M2.75 7.75a3 3 0 0 1 3-3h12.5a3 3 0 0 1 3 3v8.5a3 3 0 0 1-3 3H5.75a3 3 0 0 1-3-3v-8.5Z"
            className="fill-zinc-100 stroke-zinc-400 dark:fill-zinc-100/10 dark:stroke-zinc-500"
          />
          <path
            d="m4 6 6.024 5.479a2.915 2.915 0 0 0 3.952 0L20 6"
            className="stroke-zinc-400 dark:stroke-zinc-500"
          />
        </svg>
      )
    }
    
    export function Newsletter() {
      const [success, setSuccess] = useState(false)
      const [serverError, setServerError] = useState(null)
    
      const {
        register,
        handleSubmit,
        reset,
        formState: { errors, isSubmitting },
      } = useForm({
        resolver: yupResolver(schema),
      })
    
      async function onSubmit(values) {
        // We'll wire this up next...
        console.log(values);
      }
    
      return (
        <form
          onSubmit={handleSubmit(onSubmit)}
          className="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40"
        >
          <h2 className="flex text-sm font-semibold text-zinc-900 dark:text-zinc-100">
            <MailIcon className="h-6 w-6 flex-none" />
            <span className="ml-3">Stay up to date</span>
          </h2>
          <p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
            Get notified when I publish something new, and unsubscribe at any time.
          </p>
          <div className="mt-6 flex">
            <input
              {...register('email')}
              type="email"
              placeholder="Email address"
              aria-label="Email address"
              className="min-w-0 flex-auto appearance-none rounded-md border border-zinc-900/10 bg-white px-3 py-[calc(theme(spacing.2)-1px)] shadow-md shadow-zinc-800/5 placeholder:text-zinc-400 focus:border-indigo-500 focus:outline-none focus:ring-4 focus:ring-indigo-500/10 dark:border-zinc-700 dark:bg-zinc-700/[0.15] dark:text-zinc-200 dark:placeholder:text-zinc-500 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/10 sm:text-sm"
            />
    
            <Button type="submit" className="ml-4 flex-none">
              {isSubmitting ? 'Joining' : 'Join'}
            </Button>
          </div>
          {errors?.email || serverError ? (
            <div className="ml-3 mt-2 text-xs font-light text-red-600">
              {errors?.email?.message || serverError}
            </div>
          ) : null}
          {success ? (
            <div className="ml-3 mt-3 text-xs font-light leading-5 text-green-700">
              Thanks for joining! Check your email for a confirmation.
            </div>
          ) : null}
        </form>
      )
    }
    
    

    This should give you a presentable looking Tailwind newsletter subscribe form, complete with schema-based validation, and ready for server errors & success messages.

    Wire the Form up to the /api route

    Now we need to connect our form up to our API route.

    We will handle this in our onSubmit() handler that we provided to the form.

    /components/Newsletter.jsx
    
    //... rest of file
    
    export function Newsletter() {
      const [success, setSuccess] = useState(false)
      const [serverError, setServerError] = useState(null)
    
      const {
        register,
        handleSubmit,
        reset,
        formState: { errors, isSubmitting },
      } = useForm({
        resolver: yupResolver(schema),
      })
    
      async function onSubmit(values) {
        // Reset the server error (if there is one)
        setServerError(null)
    
        // Grab the email off of the form values
        const { email } = values
    
        try {
          // Send the request to our API
          await fetch('/api/newsletter-subscribe', {
            method: 'POST',
            // Be sure and set the content type header if using fetch()
            headers: {
              'Content-Type': 'application/json; charset=utf-8',
            },
            body: JSON.stringify({
              email,
            }),
          })
    
          // Set success to true to show the success message
          setSuccess(true)
          // Reset the form
          reset()
    
          // Hide the success message after a while
          setTimeout(() => {
            setSuccess(false)
          }, 3000)
        } catch (e) {
          // Log the error, and set server error message
          console.error(e)
          setServerError('There was an error subscribing.')
        }
      }
    
      
    
      return (
        {/* ... our form ...*/}
      )
    }
    
    

    And that's it! With that in place, you should have a nice Newsletter form that looks like this:

    Stay up to date

    Get notified when I publish something new, and unsubscribe at any time.

    The last thing we need to do is wire up our environment variables to Vercel deployments.

    Wiring up Vercel

    I'm a big fan of using CLI's when possible. It helps me learn how to save time by staying in the terminal but is also critical to gaining knowledge on CI / CD pipelines. If you're ignoring devops, don't. It can be a game changer. Don't believe me? Checkout what the illuminary Kent C. Dodds has to say about automation.

    That said, let's be sure we have the Vercel CLI installed:

    yarn global add vercel@latest
    

    Now, pretending like we're new to this, let's see what it can do... remember, no browser help here:

    vercel --help
    

    This is going to spit out a big list of commands you can run, like this:

    Vercel CLI 31.4.0
    
      ▲ vercel [options] <command | path>
    
      For deploy command help, run `vercel deploy --help`
    
      Commands:
    
        Basic
    
          deploy               [path]      Performs a deployment (default)
          dev                              Start a local development server
          env                              Manages the Environment Variables for your current Project
          git                              Manage Git provider repository for your current Project
          help                 [cmd]       Displays complete help for [cmd]
          init                 [example]   Initialize an example project
          inspect              [id]        Displays information related to a deployment
          link                 [path]      Link local directory to a Vercel Project
          ls | list            [app]       Lists deployments
          login                [email]     Logs into your account or creates a new one
          logout                           Logs out of your account
          promote              [url|id]    Promote an existing deployment to current
          pull                 [path]      Pull your Project Settings from the cloud
          redeploy             [url|id]    Rebuild and deploy a previous deployment.
          rollback             [url|id]    Quickly revert back to a previous deployment
          switch               [scope]     Switches between teams and your personal account
    
        Advanced
    
          alias                [cmd]       Manages your domain aliases
          bisect                           Use binary search to find the deployment that introduced a bug
          certs                [cmd]       Manages your SSL certificates
          dns                  [name]      Manages your DNS records
          domains              [name]      Manages your domain names
          logs                 [url]       Displays the logs for a deployment
          projects                         Manages your Projects
          rm | remove          [id]        Removes a deployment
          secrets              [name]      Manages your global Secrets, for use in Environment Variables
          teams                            Manages your teams
          whoami                           Shows the username of the currently logged in user
    
      Global Options:
    
        -h, --help                     Output usage information
        -v, --version                  Output the version number
        --cwd                          Current working directory
        -A FILE, --local-config=FILE   Path to the local `vercel.json` file
        -Q DIR, --global-config=DIR    Path to the global `.vercel` directory
        -d, --debug                    Debug mode [off]
        --no-color                     No color mode [off]
        -S, --scope                    Set a custom scope
        -t TOKEN, --token=TOKEN        Login token
    
      Examples:
    
      - Deploy the current directory
    
        $ vercel
    
      - Deploy a custom path
    
        $ vercel /usr/src/project
    
      - Deploy with Environment Variables
    
        $ vercel -e NODE_ENV=production
    
      - Show the usage information for the sub command `list`
    
        $ vercel help list
    

    It looks like the one we're going to care about is vercel env, so let's check the help for that command:

    vercel env --help
    

    Which should output something like this:

    Vercel CLI 31.4.0
    
      ▲ vercel env [options] <command>
    
      Commands:
    
        ls      [environment] [gitbranch]         List all variables for the specified Environment
        add     [name] [environment] [gitbranch]  Add an Environment Variable (see examples below)
        rm      [name] [environment] [gitbranch]  Remove an Environment Variable (see examples below)
        pull    [filename]                        Pull all Development Environment Variables from the cloud and write to a file [.env.local]
    
      Options:
    
        -h, --help                     Output usage information
        --environment                  Set the Environment (development, preview, production) when pulling Environment Variables
        --git-branch                   Specify the Git branch to pull specific Environment Variables for
        -A FILE, --local-config=FILE   Path to the local `vercel.json` file
        -Q DIR, --global-config=DIR    Path to the global `.vercel` directory
        -d, --debug                    Debug mode [off]
        --no-color                     No color mode [off]
        -t TOKEN, --token=TOKEN        Login token
        -y, --yes                      Skip the confirmation prompt when overwriting env file on pull or removing an env variable
    
      Examples:
    
      - Pull all Development Environment Variables down from the cloud
    
          $ vercel env pull <file>
          $ vercel env pull .env.development.local
    
      - Add a new variable to multiple Environments
    
          $ vercel env add <name>
          $ vercel env add API_TOKEN
    
      - Add a new variable for a specific Environment
    
          $ vercel env add <name> <production | preview | development>
          $ vercel env add DB_PASS production
    
      - Add a new variable for a specific Environment and Git Branch
    
          $ vercel env add <name> <production | preview | development> <gitbranch>
          $ vercel env add DB_PASS preview feat1
    
      - Add a new Environment Variable from stdin
    
          $ cat <file> | vercel env add <name> <production | preview | development>
          $ cat ~/.npmrc | vercel env add NPM_RC preview
          $ vercel env add API_URL production < url.txt
    
      - Remove a variable from multiple Environments
    
          $ vercel env rm <name>
          $ vercel env rm API_TOKEN
    
      - Remove a variable from a specific Environment
    
          $ vercel env rm <name> <production | preview | development>
          $ vercel env rm NPM_RC preview
    
      - Remove a variable from a specific Environment and Git Branch
    
          $ vercel env rm <name> <production | preview | development> <gitbranch>
          $ vercel env rm NPM_RC preview feat1
    

    One thing I love about the Vercel CLI is all the helpful examples they give you.

    Most CLI tools I've used (even good ones like Heroku) don't give you all the great examples for common use cases. Nice work there Vercel. 🎉

    So to add our environment variables, it looks like we're going to want this command:

    vercel env add <name>
    

    We could use the Add a new variable for a specific Environment example, and you would definitely do this if you were adding variables for something like a database, where you had specific environments or if you wanted to have a "staging" setup for ConvertKit. I don't, and I'm fine with all of my test environments working just like production for newsletter subscriptions (at this point in time).

    So I'll just use:

    vercel env add CONVERTKIT_PUBLIC_API_KEY
    

    When it prompts you for the value, paste in your key and hit enter. Then, it will ask what environments you want to add the variables. For this circumstance, press a (for all), and hit enter.

    Then, do the same thing for CONVERTKIT_SUBSCRIBE_FORM_ID.

    vercel env add CONVERTKIT_SUBSCRIBE_FORM_ID
    

    Now, you should be all set. You may need to redeploy your app if you don't have a new commit going out soon.

    Conclusion

    And there you have it! A ConvertKit newsletter subscription form that you can use on your site.

    If I were going to take this to the next steps (which I did), I would add this to my MDXComponents file so that I can just add <Newsletter /> tags whenever relevant.

    Hope this was helpful, and happy coding! 🦄⚡️

    If you enjoyed this article, please consider following me on Twitter

    Subscribe to the Newsletter

    Subscribe for exclusive tips, strategies, and resources to launch, grow, & build your SaaS team.

    Share this article on: