Subscribe to 💌 Tiny Improvements, my weekly newsletter for product builders. It's a single, tiny idea to help you build better products.

Publish your newsletter to your Remix site with the ConvertKit API

Learn how to use the ConvertKit API to publish your newsletter to your Remix site.

I publish a newsletter called Tiny Improvements

My little newsletter is all about making small, incremental improvements to your work and home life. I've been publishing it for over a year now and I've learned a lot about how to make it better.

It's a great way to share less-formal insights with the people who have given me access to their inbox - a privilege which I don't take lightly. It also helps me keep my writing skills sharp, and it's a great way to get direct feedback on my ideas.

Although the audience for my newsletter has been growing, and recently hit some milestones, I realized that I had quite a bit of great writing that went out once, via email, and was never seen again. I wanted to make it easier for people to find and read my past newsletter content without subscribing, as way to show the value it provides. I also wanted to take advantage of the SEO benefits of having past issues of my newsletter content indexed by search engines.

In this post, we'll go over how I use the ConvertKit API to publish a newsletter page for a site built with Remix.

Improve discoverability by publishing past issues

I have a second, important use case for publishing newsletters online. I also maintain my wife's website - primaryfocus.tv - which she uses to promote her business and YouTube channel (go subscribe!).

Primary Focus has a newsletter, and I wanted to make it easy for her to publish her newsletter to her website as well. In fact, I wanted to give her a way to get her newsletter on her website with zero effort - and I was pleased to find that I could do that with the ConvertKit API.

Ideally, the workflow should look like this: newsletters are drafted and published in ConvertKit, and, once sent to her subscribers, automatically published to the website as well. This way, she can focus on writing and sending her newsletter, without adding an additional workflow to update her site.

Her site is built with Remix, so this post will go over the details of implementation for publishing past ConvertKit newsletters to a remix site, using their API.

Using the ConvertKit API to publish newsletters to a Remix site

Setting up an automated newsletter page is fairly simple, at least in theory:

  1. Write newsletters and send them, using ConvertKit's broadcast features (for the sake of this post, we'll use the term "newsletter" and "broadcast" somewhat interchangeably).
  2. Use the ConvertKit API's list-broadcasts endpoint to query for every published broadcast, to populate a newsletter index page
  3. For each published broadcast, use the ConvertKit API to get the details of that broadcast, and then render the content of that broadcast on a page.

To get newsletters from the ConvertKit API, I set up 2 helper functions. First is getNewsletter(), which takes a broadcast ID and returns the details of that broadcast.

1
export const getNewsletter = async (broadcastId) => {
2
const res = await fetch(
3
`https://api.convertkit.com/v3/broadcasts/${broadcastId}?api_secret=${CONVERTKIT_API_SECRET}`
4
);
5
6
// rename "broadcast" to "newsletter" for consistency
7
const { broadcast: newsletter } = await res.json();
8
9
return newsletter;
10
};

The second is getAllNewsletters(), which returns a list of all published broadcasts. In its simplest form, this function looks like this:

1
export const getAllNewsletters = async () => {
2
const response = await fetch(
3
`https://api.convertkit.com/v3/broadcasts?api_secret=${CONVERTKIT_API_SECRET}`
4
);
5
const data = await response.json();
6
7
// rename "broadcasts" to "newsletters" for consistency
8
const { broadcasts: newsletters } = data;
9
10
return newsletters;
11
};

Keeping your ConvertKit API Secret key safe on Remix sites

Remix provides a has a handy filename convention to ensure certain code only runs serverside - any file that ends in .server.ts (for TypeScript) and .server.js (for JavaScript) will only be run on the server. This is perfect for keeping our ConvertKit API secret safe. I created a file called /app/lib/util/convertkit.server.js with the two functions above, to make sure to only ever access the secret key in there.

Rendering the newsletter index page

Now that we have a way to get a list of all published newsletters, we can use that to render a page with a preview of each past issue. I created a new file called /app/routes/newsletter/index.jsx to render the newsletter index page.

note: I stripped out the styling/presentation code from this example to keep it focused on data loading logic.

1
import { useLoaderData } from '@remix-run/react';
2
3
import { getAllNewsletters } from '~/lib/util/convertKit.server';
4
import { NewsletterListItem, NewsletterCTA } from '~/components';
5
6
export const loader = async ({ params, request }) => {
7
return {
8
newsletters: await getAllNewsletters(),
9
};
10
};
11
12
const NewsletterListPage = () => {
13
const { newsletters } = useLoaderData();
14
15
return (
16
<>
17
<h1>Read past newsletters</h1>
18
{newsletters.map((newsletter) => (
19
<NewsletterListItem newsletter={newsletter} key={newsletter.id} />
20
))}
21
22
{/* make sure to add a "hey please subscribe to the newsletter" widget to this page! */}
23
<NewsletterCTA />
24
</>
25
);
26
};
27
28
export default NewsletterListPage;

This is a fairly standard implementation, if you're familiar with Remix. We use the loader pattern to fetch the list of newsletters, and pass them to the render function for the component with the useLoaderData() hook. We then render a list of NewsletterListItem components, which shows a header image and title for each newsletter, along with a link to the page for that specific newsletter.

Rendering a single newsletter page

Now that we have a page listing all published newsletters, we need to create a page template to render a single newsletter. I created a new file called /app/routes/newsletter/$id.jsx to render the newsletter index page (note, once again, that presentation and styling code is removed here for simplicify):

1
import { useLoaderData } from '@remix-run/react';
2
import {
3
broadcastTemplateParse,
4
newsletterHasValidThumbnail,
5
} from '~/lib/util/convertKit';
6
import NewsletterCTA from '~/components';
7
import config from '~/config';
8
9
import { getNewsletter } from '~/lib/util/convertKit.server';
10
11
// render meta tags on page for SEO
12
export const meta = ({ data }) => {
13
const { newsletter, canonical } = data;
14
const publishedAt = new Date(newsletter.published_at);
15
16
const description = `Primary focus newsletter: ${
17
newsletter.subject
18
}, published on ${publishedAt.toLocaleDateString()}`;
19
20
return {
21
description,
22
title: newsletter.subject
23
? `${newsletter.subject} - ${config.meta.title}`
24
: config.meta.title,
25
'og:image': newsletterHasValidThumbnail(newsletter)
26
? newsletter.thumbnail_url
27
: null,
28
'og:type': 'article',
29
'og:title': newsletter.subject,
30
'og:description': description,
31
'og:url': canonical,
32
'twitter:card': 'summary_large_image',
33
};
34
};
35
36
export const loader = async ({ params, request }) => {
37
const { id } = params;
38
const newsletter = await getNewsletter(id);
39
40
return { newsletter };
41
};
42
43
const NewsletterPage = () => {
44
const { newsletter } = useLoaderData();
45
46
const publishedAt = new Date(newsletter.published_at);
47
return (
48
<>
49
<h1>{newsletter.subject}</h1>
50
<p className="publish-date">{publishedAt.toLocaleDateString()}</p>
51
<div
52
dangerouslySetInnerHTML={{
53
__html: broadcastTemplateParse({ template: newsletter.content }),
54
}}
55
/>
56
<NewsletterCTA />
57
</>
58
);
59
};
60
61
export default NewsletterPage;

Once again, we use the loader pattern to query the ConvertKit API for details about this specific newsletter, and pair that with useLoaderData() to provide that information to the page render function. We also use the meta function pattern to render meta tags for SEO purposes - you can read more about that in the Remix docs.

Render the body using dangersoulySetInnerHTML

To render the contents of the newsletter, we are using a scary-looking React API pattern - dangerouslySetInnerHTML. This is a React API that allows you to render HTML that you have received from an API, and is generally considered a security risk. However, in this case, we are using it to render HTML that we have generated ourselves, and we are at least confident that it is somewhat safe to render. Generally speaking, this is a technique that shouldn't be used unless you are confident that the HTML you are rendering is safe.

Helper functions for displaying ConvertKit newsletter content

We're also using 2 helper functions, which are defined in /app/lib/util/convertKit.js (note that these are not in convertkit.server.js, since we need them on the client side):

1
import { Liquid } from 'liquidjs';
2
const engine = new Liquid();
3
4
export const broadcastTemplateParse = ({ template, data }) => {
5
const t = template.replaceAll('&quot;', '"');
6
const res = engine.parseAndRenderSync(t, data);
7
return res;
8
};
9
10
export const newsletterHasValidThumbnail = (newsletter) => {
11
const { thumbnail_url: thumbnailUrl } = newsletter;
12
if (!thumbnailUrl) return false;
13
if (thumbnailUrl.startsWith('https://functions-js.convertkit.com/icons'))
14
return false;
15
return true;
16
};

The newsletterHasValidThumbnail function is a helper function that checks if the newsletter has a valid thumbnail image. ConvertKit provides a default thumbnail image for all newsletters, but you can also upload your own, by adding an image to your newsletter's body. This function checks if the thumbnail is the default image, and if so, returns false so we can skip rendering it.

The broadcastTemplateParse function is a helper function that takes a newsletter template and parses it with the Liquid templating language. This is how ConvertKit allows you to customize the HTML of your newsletter, and use dynamic data like the subscriber's name, or the date the newsletter was sent.

An important caveat: There are lots of little cases where this might not work perfectly, due to all of the amazing things you can do with Liquid in ConvertKit. At the moment, my newsletter uses fairly basic liquid customization - this may not do the trick for you if you're using more advanced features. Please test your implementation thoroughly!

Adding more detail to the index page

At the time of writing this post, ConvertKit's v3 API is in "active development" - and as such, it's likely not feature complete. For example, the current API for list-broadcasts provides a minimal amount of data about each broadcast:

1
curl https://api.convertkit.com/v3/broadcasts?api_secret=<your_secret_api_key>
1
{
2
"broadcasts": [
3
{
4
"id": 1,
5
"created_at": "2014-02-13T21:45:16.000Z",
6
"subject": "Welcome to my Newsletter!"
7
},
8
{
9
"id": 2,
10
"created_at": "2014-02-20T11:40:11.000Z",
11
"subject": "Check out my latest blog posts!"
12
},
13
{
14
"id": 3,
15
"created_at": "2014-02-29T08:21:18.000Z",
16
"subject": "How to get my free masterclass"
17
}
18
]

Since we want to create an index page that shows a thumbnail for each newsletter, we need to query the API for more information about each newsletter. At the moment, the best way to do this is to query the API for each newsletter individually. It's not ideal, since we need to send an API call for each newsletter shown on the index page, but I'm hoping that with a bit of feedback, ConvertKit will add more data to the list-broadcasts API endpoint.

We already have a function to query the API for a single newsletter (remember getNewsletter from earlier?), so we can use that to query the API for each newsletter on the index page.

Filter out un-published newsletters

The list-broadcasts endpoint also doesn't currently return whether or not a given broadcast was published yet - which means that if you draft a future broadcast on your ConvertKit dashboard, it will still be returned by the list API. I don't want to share these yet-to-be-sent newsletters on any of my sites, so we'll add some logic to getAllNewsletters to filter out any newsletters that haven't been published yet.

Don't render newsletters with duplicate subject lines

There are some cases where I send the same newsletter multiple times, with the same subject line, to different audiences - usually because I've forgotten to include some details in the original broadcast. This results in multiple broadcasts with essentially the same content. I don't want to show these on my site, so I added some logic to deduple any newsletters that have the same subject line, displaying only the most recent one (which should be the most correct, in theory).

This is the updated code for getAllNewsletters in convertKit.server.js:

1
export const getAllNewsletters = async () => {
2
const response = await fetch(
3
`https://api.convertkit.com/v3/broadcasts?api_secret=${CONVERTKIT_API_SECRET}`
4
);
5
const data = await response.json();
6
const { broadcasts } = data;
7
8
// get details for each individual newsletter
9
const newsletters = await Promise.all(
10
broadcasts.map((broadcast) => getNewsletter(broadcast.id))
11
);
12
13
// hackish dedupe here - sometimes we publish a newsletter a _second_ time as a correction, or to a different audience.
14
// In those cases, they should always have the same subject. We're using subject as a way to deduplicate newsletters in that case
15
const dedupedBySubject = {};
16
17
// return only newsletters that have been published, and sort by most recent to oldest
18
newsletters
19
.filter((newsletter) => !!newsletter.published_at)
20
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at))
21
.forEach((newsletter) => {
22
// the first one we encounter in this order will be the newest, so if there's one already we don't make any changes to the object map
23
if (!dedupedBySubject[newsletter.subject])
24
dedupedBySubject[newsletter.subject] = newsletter;
25
});
26
27
let nls = [];
28
// iterate over the map we have of subject->newsletter, and push into a fresh array, which we'll return
29
for (let subject in dedupedBySubject) {
30
nls.push(dedupedBySubject[subject]);
31
}
32
33
return nls;
34
};

And with that, our journey is complete - we've created a /newsletter page that shows a list of all of our newsletters, with a thumbnail image for each one. We've also added a /newsletter/$id page that shows the full content of a given newsletter.

Wrap-up

I hope this post has been helpful to you, and that you've learned something new about how to use ConvertKit's API to create a custom newsletter index page for your Remix site. To see what this looks like in practice, head over to primaryfocus.tv/newsletter.

In my next post, I'll share how I used a similar implementation in Next.js on my site - mikebifulco.com.

If you have any questions, or if you've found a bug in my code, please feel free to reach out to me on Twitter @irreverentmike!

*It's a flower from a plum tree. Like it?*

SHIP PRODUCTS
THAT MATTER

💌 Tiny Improvements: my weekly newsletter sharing one small yet impactful idea for product builders, startup founders, and indiehackers.

It's your cheat code for building products your customers will love. Learn from the CTO of a Y Combinator-backed startup, with past experience at Google, Stripe, and Microsoft.

    Join the other product builders, and start shipping today!