Table of contents
Last week newsletters started popping up on Twitter profiles, using their recently purchased service, Revue. But how do you create a newsletter and add subscribers from your website? Using Vercel API routes you can start collecting subscribers in minutes. Here's a quick guide.
Twitter & Revue
Earlier this year Twitter purchased Revue, a newsletter service, and last week they started integrating newsletter links into Twitter profiles:
I've been giving it a try, and I quite like it. It may not have the advanced functionality of some of its competitors yet, but it does everything I need to quickly send out articles to my followers, along with having a very generous free plan—so I've made the switch from Mailchimp.
The free plan offers unlimited newsletters (or issues), unlimited subscribers, custom from addresses, and a basic newsletter editor. It also allowed me to quickly import my Mailchimp subscribers.
Sign up to Revue
First we need to sign up to Revue; I'd recommend registering with your Twitter account, if you're planning to attach it to your profile. After signing up, go to Account settings then Integrations (or click here), and find your API key, right at the bottom of the page (it'll be around this length):
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
If you don't have a project ready, and would like to follow along, I've created a Next.js template to use:
Setup
To connect to Revue, we'll be using fetch
and FormData
, two Web APIs (we'll get into why later). Neither of these are supported in Node.js, so we'll install two
polyfills:
npm install node-fetch form-data
I'm also choosing to store my secret API key as an environment variable in my project:
REVUE_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Create API route
Great, we can get started with the subscription API route. From the front-end we'll send a JSON object similar to this:
{ "email": "email@example.com" }
We can then create a Vercel API route at /api/subscribe.js
, retrieve the email using req.body
, and send an error
if no email has been submitted:
export default async function (req, res) {
const { email } = JSON.parse(req.body)
if (!email) {
res.status(400).json({ error: 'No email submitted' })
return
}
// ...
}
Sending Revue API request
All POST requests sent to the Revue API require multipart/form-data
encoding, which
is why we're using FormData
as the body of the request:
// Create body to be sent to Revue
const formData = new FormData()
formData.append('email', email)
formData.append('double_opt_in', 'false')
Revue also requires an Authorization
header, which we can add to the fetch options. We'll then create data
, the parsed JSON sent from Revue:
const url = 'https://www.getrevue.co/api/v2/subscribers'
const secret = process.env.REVUE_SECRET_KEY
// Call Revue API, get result & data
const result = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Token ${secret}`
},
// `formData` from above
body: formData
})
const data = await result.json()
Returning the result
Finally, we'll check to see if the request worked, and then return an error, or the data:
// Revue API error, send error
if (!result.ok) {
res.status(500).json({ error: data.error.email[0] })
return
}
// Success, send data
res.status(200).json({ data })
Putting it together
If we put everything together, it looks like this:
import FormData from 'form-data'
import fetch from 'node-fetch'
const url = 'https://www.getrevue.co/api/v2/subscribers'
const secret = process.env.REVUE_SECRET_KEY
export default async function (req, res) {
const { email } = JSON.parse(req.body)
if (!email) {
res.status(400).json({ error: 'No email submitted' })
return
}
const formData = new FormData()
formData.append('email', email)
formData.append('double_opt_in', 'false')
const result = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Token ${secret}`
},
body: formData
})
const data = await result.json()
if (!result.ok) {
res.status(500).json({ error: data.error.email[0] })
return
}
res.status(200).json({ data })
}
We now have a working API route!
Connect the front-end
Connecting the front-end can be done within an async
function. Because we return
either a data
or an error
property from the API route (but not both), error checking is neat and tidy:
async function addSubscriber (email) {
// The location of your API route
const url = '/api/subscribe'
const { data, error } = await fetch(url, {
method: 'POST',
body: JSON.stringify({ email })
}).then(res => res.json())
if (error) {
console.log('Error:', error)
return
}
console.log('Success:', data)
}
Done! We've successfully connected to Revue, and it's time to get building subscribe forms. Here's an example newsletter box with a sample API return value:
Example of API route input and output
{
"email": ""
}
{
"data": {
"id": 276564104,
"list_id": 300978,
"email": "",
"first_name": null,
"last_name": null,
"last_changed": "2024-02-08T09:30:41.203Z"
}
}
After trying out your new system, take a look at the subscribers page on Revue to see your new subscribers.
Get newsletter history
The Revue API allows for other functions too, for example we can grab a list of previously posted newsletters, and write them to the page. Here's a live example of my previous issues, with links:
This can be implemented using the issues part of the API:
Get all issues
import fetch from 'node-fetch'
const url = 'https://www.getrevue.co/api/v2/issues'
const secret = process.env.REVUE_SECRET_KEY
export default async function (req, res) {
const result = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Token ${secret}`
}
})
const data = await result.json()
if (!result.ok) {
res.status(500).json({ error: data.error })
return
}
// Returns array of all issues
res.status(200).json({ data })
}
Get all issues (client)
async function getAllIssues (email) {
// The location of your API route
const url = '/api/issues'
const { data, error } = await fetch(url).then(res => res.json())
if (error) {
console.log('Error:', error)
return
}
console.log('Success:', data)
}
Pretty easy stuff!
Snippets
Here's some more Revue API routes that allow you to implement other features:
Get latest issue
import fetch from 'node-fetch'
const url = 'https://www.getrevue.co/api/v2/issues/latest'
const secret = process.env.REVUE_SECRET_KEY
export default async function (req, res) {
const result = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Token ${secret}`
}
})
const { issue, error } = await result.json()
if (!result.ok) {
res.status(500).json({ error })
return
}
// Returns array containing one issue
res.status(200).json({ data: issue })
}
Get profile URL
import fetch from 'node-fetch'
const url = 'https://www.getrevue.co/api/v2/accounts/me'
const secret = process.env.REVUE_SECRET_KEY
export default async function (req, res) {
const result = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Token ${secret}`
}
})
const { profile_url, error } = await result.json()
if (!result.ok) {
res.status(500).json({ error })
return
}
// Returns string e.g. "https://www.getrevue.co/profile/ctnicholasdev"
res.status(200).json({ data: profile_url })
}
Get all subscribers (best keep this on the back-end!)
import fetch from 'node-fetch'
const url = 'https://www.getrevue.co/api/v2/subscribers'
const secret = process.env.REVUE_SECRET_KEY
export default async function (req, res) {
const result = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Token ${secret}`
}
})
const data = await result.json()
if (!result.ok) {
console.log(data.error)
res.status(500).json({ error: 'Error retrieving subscribers' })
return
}
// `data` is an array of subscribers
const backendResult = doBackendStuff(data)
res.status(200).json({ data: backendResult })
}
Add an item to the latest unpublished newsletter
import fetch from 'node-fetch'
import FormData from 'form-data'
const latestUrl = 'https://www.getrevue.co/api/v2/issues/current'
const itemUrl = id => `https://www.getrevue.co/api/v2/issues/${id}/items`
const secret = process.env.REVUE_SECRET_KEY
export default async function (req, res) {
const id = await getLatestId()
if (id.error) {
res.status(500).json({ error: id.error })
return
}
// Get this into from JSON.parse(req.body) in your actual project (except id)
const exampleData = {
id: id.data,
url: 'https://ctnicholas.dev',
image: 'iVBORw0KGgoAAAANSUhE..', // Base 64 image
caption: 'Visit my blog!'
}
const formData = createFormData(exampleData)
const result = await fetch(itemUrl(id), {
method: 'POST',
headers: {
Authorization: `Token ${secret}`
},
body: formData
})
const data = await result.json()
if (!result.ok) {
res.status(500).json({ error: data.error })
return
}
// Returns object containing the issue data added
res.status(200).json({ data })
}
// Create FormData for edit issue API
function createFormData ({ id, url, image, caption }) {
const fields = {
issue_id: id,
type: image ? 'image' : '',
caption,
image,
url
}
const formData = new FormData()
Object.entries(fields).forEach(([key, val]) => {
if (val) {
formData.append(key, val)
}
})
return formData
}
// Get the id of the current non-published issue
async function getLatestId () {
const result = await fetch(latestUrl, {
method: 'GET',
headers: {
Authorization: `Token ${secret}`
}
})
const data = await result.json()
if (data.error) {
return { error: data.error }
}
// Returns a Number e.g. 737051
return { data: data[0].id }
}
Summary
With Revue, setting up a newsletter is easier (and much cheaper) than ever. I hope you've enjoyed reading this article (and playing with the Shiba, I enjoyed building that!), and if you'd like to try a real Revue subscribe form... there's one below!