How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES
Twitter will shut down Revue on January 18, 2023, which I previously used as a newsletter provider for Weekly Vue News.
There exist potent alternatives like Substack, Buttondown, beehiv, and more. But I decided to build a custom solution for these reasons:
- Manage my content as Markdown files in my project's repository.
- Emails can use the same CSS styles as the website.
- A cheap solution that does not get too expensive.
This article explains how I built a custom newsletter service using Nuxt 3, Supabase, Serverless, and Amazon SES.
General Note
My proposed solution is not only limited to the mentioned frameworks & tools. You could easily accomplish the same functionality with other frameworks & tools of your choice.
Backend
Let's start by looking at the backend code I use for my newsletter solution.
Database Model
I use Supabase to store two database tables.
The first table stores the list of subscribers and has the following schema:
id
: primary key as an integercreated_at
: timestamp indicating when the user was added to the tableemail
: email address that should receive the newsletterverification_token
: a UUID used for the confirmation emailunsubscribe_token
: a UUID used for the unsubscribe mechanismverified
: A boolean indicating if the user has confirmed the confirmation mail
The second table stores the list of scheduled issues and has the following schema:
id
: primary key as an integerissue_id
: the ID of the newsletter issuehtml
: the html string that should be sent to the subscribers in the body of the emailtitle
: a string which will is used as a subject in the emails sent to the subscribersscheduled_at
: timestamp when the issue should or has been publishedpublished
: A boolean indicating if the issue is publishedsend_count
: A number that stores how many subscribers the issue has been sent tobeacon_data
: JSON object that stores analytics information
Amazon SES
I decided to send emails using Amazon SES; this tutorial will teach you how to set up and get started using Amazon SES. I use Nodemailer with SES transport to send my emails using Amazon SES. Nodemailer SES transport is a wrapper around aws.SES
from the @aws-sdk/client-ses package.
The main benefit is that Nodemailer provides rate limiting for SES out of the box. SES can tolerate short spikes, but you can’t flush all your emails at once and expect these to be delivered. Luckily, Amazon granted my request to increase the sending quota to 50,000 messages per day and a maximum sending rate of 14 messages per second.
A quick note about SES pricing: You pay only for what you use with no minimum fees or mandatory service usage. The current price is $0.10/1000 emails, which is cheap compared to other newsletter services like Substack, Buttondown or beehiv.
Enough theory; let's take a look at the code I wrote to wrap the Nodemailer integration:
1import nodemailer from 'nodemailer'
2import aws from '@aws-sdk/client-ses'
3
4export const sendEmail = async (fromAddress: string, toAddress: string, subject: string, bodyHtml: string) => {
5 const config = useRuntimeConfig()
6
7 const ses = new aws.SES({
8 region: 'eu-central-1',
9 credentials: {
10 accessKeyId: config.MY_AWS_ACCESS_KEY_ID,
11 secretAccessKey: config.MY_AWS_SECRET_ACCESS_KEY,
12 },
13 })
14
15 const transporter = nodemailer.createTransport({
16 SES: { ses, aws },
17 sendingRate: 14, // max 14 messages/second
18 })
19
20 return transporter.sendMail({ from: fromAddress, to: toAddress, subject, html: bodyHtml })
21}
Subscribe
Info
As my newsletter website is built with Nuxt 3, I use Nuxt server routes for the backend implementation. Additionally, I use Nuxt Supabase as a wrapper around supabase-js to enable usage and integration within Nuxt.
If a new user wants to subscribe to the newsletter, we need to trigger an endpoint that receives the user's email address:
1import { serverSupabaseServiceRole } from '#supabase/server'
2import { v4 as uuidv4 } from 'uuid'
3import * as EmailValidator from 'email-validator'
4import { sendEmail } from '~/lib/ses-client'
5
6export default defineEventHandler(async (event) => {
7 const client = serverSupabaseServiceRole(event)
8
9 const body = await readBody(event)
10 const { email } = body
11
12 if (!email) {
13 console.error('Email is required')
14 return { error: 'Email is required' }
15 }
16
17 if (!EmailValidator.validate(email)) {
18 console.error(`Email ${email} is invalid`)
19 return { error: `Email ${email} is invalid` }
20 }
21
22 try {
23 const verificationToken = uuidv4()
24
25 const { error: insertError } = await client
26 .from('newsletter-subscribers')
27 .insert({ email, verification_token: verificationToken, unsubscribe_token: uuidv4() })
28
29 if (insertError) {
30 console.error('Failed to insert subscriber', insertError)
31 if (insertError.code === '23505') {
32 return { error: 'You are already subscribed with this email.' }
33 }
34 return { error: insertError }
35 }
36
37 const html = `
38 <div style="padding: 20px; display: flex; flex-direction: column; gap: 20px;">
39 <p>Hey, thanks for signing up for my weekly Vue newsletter!</p>
40 <p>Before I can send you any more emails though, I need you to confirm your subscription by clicking this link:</p>
41 <a href="https://weekly-vue.news/confirm-subscription?token=${verificationToken}" target="_blank" rel="noopener">👉 Confirm subscription</a>
42 </div>
43 `
44
45 return await sendEmail(email, 'Confirm registration', html)
46 } catch (e) {
47 console.error('Failed to send email.', e)
48 return { error: e }
49 }
50})
Let's analyze the above code. The first step is to validate the email and return an error if it is missing or invalid:
1import * as EmailValidator from 'email-validator'
2
3if (!email) {
4 console.error('Email is required')
5 return { error: 'Email is required' }
6}
7
8if (!EmailValidator.validate(email)) {
9 console.error(`Email ${email} is invalid`)
10 return { error: `Email ${email} is invalid` }
11}
Next, we try to insert a new subscriber into the subscriber table with the provided email and return an error if we already have a subscriber with the given email address:
1const verificationToken = uuid4()
2
3const { error: insertError } = await client
4 .from('newsletter-subscribers')
5 .insert({ email, verification_token: verificationToken, unsubscribe_token: uuidv4() })
6
7if (insertError) {
8 console.error('Failed to insert subscriber', insertError)
9 if (insertError.code === '23505') {
10 return { error: 'You are already subscribed with this email.' }
11 }
12 return { error: insertError }
13}
Finally, we send the confirmation mail that contains a link with the generated verificationToken
as query parameter:
1const html = `
2<div style="padding: 20px; display: flex; flex-direction: column; gap: 20px;">
3 <p>Hey, thanks for signing up for my weekly Vue newsletter!</p>
4 <p>Before I can send you any more emails though, I need you to confirm your subscription by clicking this link:</p>
5 <a href="https://weekly-vue.news/confirm-subscription?token=${verificationToken}" target="_blank" rel="noopener">👉 Confirm subscription</a>
6</div>
7`
8
9return await sendEmail(email, 'Confirm registration', html)
Clicking on this link in the frontend will trigger the following backend endpoint:
1import { serverSupabaseServiceRole } from '#supabase/server'
2
3export default defineEventHandler(async (event) => {
4 const client = serverSupabaseServiceRole(event)
5
6 const query = getQuery(event)
7 const { token } = query
8
9 if (!token) {
10 return { error: 'Verification token is missing' }
11 }
12
13 const { data: subscriberData, error: selectError } = await client
14 .from('newsletter-subscribers')
15 .select()
16 .eq('verification_token', token)
17
18 if (selectError) {
19 console.error('Failed to confirm subscription', selectError)
20 return { error: selectError.details }
21 } else {
22 const { error: updateError } = await client
23 .from('newsletter-subscribers')
24 .update({ verified: true })
25 .eq('verification_token', token)
26
27 if (updateError) {
28 console.error('Update error', updateError)
29 return { error: updateError }
30 }
31
32 return { error: null }
33 }
34})
We query the subscriber database for an entry where the verification_token
equals the given token
query parameter.
If an entry is found, we set its verified
value to true
.
A verified user is subscribed and will receive the newsletter emails.
Info
The advantages of using subscription confirmation emails and implementing a double opt-in process:
Unsubscribe
Of course, we must provide a way to unsubscribe from the newsletter. It's mainly based on the unsubscribe_token
column of the subscribers database table:
1import { serverSupabaseServiceRole } from '#supabase/server'
2
3export default defineEventHandler(async (event) => {
4 const client = serverSupabaseServiceRole(event)
5
6 const body = await readBody(event)
7 const { token } = body
8
9 if (!token) {
10 console.error('Token is required')
11 return { error: 'Token is required' }
12 }
13
14 try {
15 const { error: deleteError, data } = await client
16 .from('newsletter-subscribers')
17 .delete()
18 .eq('unsubscribe_token', token)
19
20 if (deleteError) {
21 console.error(`Failed to unsubscribe "${token}"`, deleteError)
22 return { error: deleteError }
23 }
24
25 return { message: `Successfully unsubscribed "${token}"` }
26 } catch (e) {
27 console.error(`Failed to unsubscribe "${token}"`, e)
28 return { error: e }
29 }
30})
We query the subscriber database for an entry where the unsubscribe_token
equals the given token
query parameter.
If an entry is found, we remove it from the database, so he will not receive any further newsletter emails.
Schedule Issue
We need to provide an endpoint to schedule an issue:
1import { serverSupabaseServiceRole } from '#supabase/server'
2import { Database } from '~/types/supabase'
3
4export default defineEventHandler(async (event) => {
5 const client = serverSupabaseServiceRole<Database>(event)
6
7 const body = await readBody(event)
8 const { issueId, html, title, scheduleDate } = body
9
10 if (!issueId || !html || !title || !scheduleDate) {
11 return {
12 error: `Parameter missing, required are [issueId, html, title, scheduleDate]. Received: ${JSON.stringify(body)}`,
13 }
14 }
15
16 const { error: insertIssueError } = await client.from('newsletter-issues').upsert(
17 {
18 issue_id: issueId,
19 html: html,
20 title: title,
21 published: false,
22 scheduled_at: scheduleDate,
23 },
24 { onConflict: 'issue_id' }
25 )
26
27 if (insertIssueError) {
28 console.error('Failed to upsert issue', insertIssueError)
29 return { error: 'Failed to upsert issue' }
30 }
31
32 return { error: null }
33})
This simple function upserts an issue based on the given parameters in the event body.
Serverless Cron Function
My newsletter is sent every Monday at 3 pm. I wrote a serverless cron function that checks if a scheduled issue exists and then sends it to all subscribers. Therefore I used the Serverless framework.
The serverless configuration:
1org: org
2app: app
3service: service
4
5frameworkVersion: '3'
6
7provider:
8 name: aws
9 region: eu-central-1
10 runtime: nodejs14.x
11 environment:
12 SUPABASE_URL: ${ssm:secret-supabase-url}
13 SUPABASE_SERVICE_KEY: ${ssm:secret-supabase-service-key}
14
15functions:
16 publish:
17 handler: publish.run
18 timeout: 900
19 events:
20 - http:
21 method: 'POST'
22 path: /newsletter/publish
23 async: true
24 cors: true
25 # Invoke Lambda function weekly on Monday at 3pm
26 - schedule: cron(0 14 ? * MON *)
And the corresponding code for the Lambda function. I removed the error handling in the following code snippet to keep the code clean and concise:
1'use strict'
2
3const supabase = require('@supabase/supabase-js')
4const aws = require('aws-sdk')
5const nodemailer = require('nodemailer')
6
7const FROM_ADDRESS = 'newsletter@weekly-vue.news'
8
9module.exports.run = async (event, context) => {
10 let requestBody = event
11 if (event.body) {
12 try {
13 requestBody = JSON.parse(event.body)
14 } catch (error) {
15 requestBody = event.body
16 }
17 }
18
19 const time = new Date()
20 console.log(`Cron function "${context.functionName}" ran at ${time} with event ${JSON.stringify(requestBody)}`)
21
22 const supabaseClient = supabase.createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_KEY)
23 const ses = new aws.SES()
24 const transporter = nodemailer.createTransport({
25 SES: { ses, aws },
26 sendingRate: 14, // max 14 messages/second
27 })
28
29 // get verified subscribers
30 const { data: subscriberData, error: selectError } = await supabaseClient
31 .from('newsletter-subscribers')
32 .select()
33 .eq('verified', true)
34
35 // get stored issue that haven't been published
36 let { data: unpublishedNewsletterIssues, error: selectIssuesError } = await supabaseClient
37 .from('newsletter-issues')
38 .select()
39 .eq('published', false)
40
41 /** ⚠️☠️ SENDING MAILS TO ALL SUBSCRIBERS ⚠️☠️ */
42 // find scheduled issue
43 const todayDate = new Date()
44 const issueScheduledForToday = unpublishedNewsletterIssues.find((issue) => {
45 const issueScheduleDate = new Date(issue.scheduled_at)
46 return (
47 todayDate.getFullYear() === issueScheduleDate.getFullYear() &&
48 todayDate.getMonth() === issueScheduleDate.getMonth() &&
49 todayDate.getDate() === issueScheduleDate.getDate()
50 )
51 })
52
53 if (!issueScheduledForToday) {
54 return {
55 statusCode: 400,
56 body: JSON.stringify({
57 message: `Found no unpublished issue that is scheduled for today: ${JSON.stringify(
58 unpublishedNewsletterIssues
59 )}`,
60 }),
61 }
62 } else {
63 const emailValues = await Promise.allSettled(
64 subscriberData.map(async (subscriber) => {
65 const { email, unsubscribe_token } = subscriber
66
67 return transporter.sendMail({
68 from: FROM_ADDRESS,
69 to: email,
70 subject: issueScheduledForToday.title,
71 html: issueScheduledForToday.html,
72 })
73 })
74 )
75
76 const successEmails = emailValues.filter((v) => v.status === 'fulfilled')
77 const failedEmails = emailValues.filter((v) => v.status === 'rejected')
78
79 console.log('Result sending emails', { successEmails: successEmails.length, failedEmails: failedEmails.length })
80
81 // update published status
82 const { error: updatePublishedError } = await supabaseClient
83 .from('newsletter-issues')
84 .update({
85 published: true,
86 send_count: successEmails.length,
87 })
88 .eq('issue_id', issueScheduledForToday.issue_id)
89 if (updatePublishedError) {
90 console.error('Failed to update published status', updatePublishedError)
91 }
92
93 return {
94 statusCode: 200,
95 body: JSON.stringify({
96 total: subscriberData.length,
97 success: successEmails.length,
98 failures: failedEmails.length,
99 }),
100 }
101 }
102}
A lot is going on in this function; let's break it down:
- Initialize Supabase & Nodemailer clients
- Get all verified subscribers
- Get all stored issues that haven't been published yet
- Find the unpublished issue which
schedule_at
timestamp is today - Send this issue to all verified subscribers
- Set
published
to true and updatesend_count
in the stored issue
Frontend
Let's look at the frontend part of my custom-built newsletter solution. I won't explain every component, as I mainly use Nuxt's Data Fetching composables to trigger the above-defined backend endpoints and display the result.
But one interesting aspect is the generation of the HTML string I send via email to my subscribers.
Generating HTML string of the rendered Markdown file
I use Nuxt Content to store my newsletter issues as Markdown files. Here is a simple example:
1---
2title: 'Weekly Vue News #3 - Any Tip'
3date: '2023-01-02T13:00:00.231Z'
4id: 3
5---
6
7:issue-header
8
9Hi 👋
10
11Have a nice week ☀️
12:divider
13
14## Vue Tip: Any Tip
15
16## Curated Vue Content
17
18::external-link{url="https://github.com/RomanHotsiy/commitgpt" title="🛠️ commitgpt"}
19👉🏻 Automatically generate commit messages using ChatGPT.
20::
21
22## Quote of the week
23
24## JavaScript Tip: Any Tip
25
26## Curated Web Development Content
These files are rendered on a Nuxt page using <ContentRenderer>
from Nuxt Content :
1<script setup lang="ts">
2const route = useRoute()
3const user = useSupabaseUser()
4
5const content = ref(null)
6
7const { data } = await useAsyncData('issue', () => queryContent(route.path).findOne())
8</script>
9
10<template>
11 <main>
12 <IssueAdminControls v-if="user" :issue="data" :content-html="content?.outerHTML" />
13 <div v-if="data" ref="content">
14 <ContentDoc v-slot="{ doc }">
15 <h1>{{ doc.title }}</h1>
16 <ContentRenderer :value="doc" />
17 </ContentDoc>
18 </div>
19 <div v-else>
20 <h1>Not Found</h1>
21 <div class="flex justify-center">
22 <NuxtLink to="/issues">Browse issues</NuxtLink>
23 </div>
24 </div>
25 </main>
26</template>
Using Supabase Auth I provide a way to log in as admin and scheduling issues:
1<script lang="ts" setup>
2import { Ref } from 'vue'
3import juice from 'juice'
4import { ParsedContent } from '@nuxt/content/dist/runtime/types'
5
6const props = withDefaults(defineProps<{ issue: ParsedContent | null; contentHtml?: string }>(), {})
7
8const scheduleDate = ref(null)
9
10const { data: issueData, error: issueError, pending } = await useFetch(`/api/issue?issueId=${props.issue?.id}`)
11
12const getHtml = (): string => {
13 if (!props.contentHtml) {
14 return '<p>Oops, here should be some content....</p>'
15 }
16
17 const allCSS = [...document.styleSheets]
18 .map((styleSheet) => {
19 try {
20 return [...styleSheet.cssRules].map((rule) => rule.cssText).join('')
21 } catch (e) {
22 console.log('Access to stylesheet %s is denied. Ignoring...', styleSheet.href)
23 }
24 })
25 .filter(Boolean)
26 .join('\n')
27
28 return juice(`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
29<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
30<head>
31 <meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
32 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
33 <style>
34 body { padding: 20px; background: white !important; color: black !important; }
35 a { color: black !important }
36 hr { border-color: black !important }
37 h1,h2,h3,h4,h5,h6 { color: black !important }
38 blockquote > p { color: white !important }
39 code { color: white !important }
40 </style>
41 <style>${allCSS}</style>
42</head>
43<body>
44 ${props.contentHtml.replace(/<!--(?:(?!-->)[\s\S])*-->/g, '')}
45</body>
46</html>`)
47}
48
49const schedule = async () => {
50 const { data, error } = await useFetch('/api/issue', {
51 method: 'POST',
52 body: JSON.stringify({
53 issueId: props.issue.id,
54 html: getHtml(),
55 title: props.issue.title,
56 scheduleDate: scheduleDate.value,
57 }),
58 })
59 // ...
60}
61</script>
62
63<template>
64 <div class="p-4 border border-orange-400 rounded-md mb-8">
65 <h3>Admin Controls</h3>
66 <div class="flex flex-wrap gap-8">
67 <input v-model="scheduleDate" type="date" class="h-10 overflow-visible text-white" />
68 <BaseButton :@click="schedule" class="bg-orange-400">Schedule</BaseButton>
69 </div>
70 </div>
71</template>
Let's now focus on the getHtml()
method in IssueAdminControls.vue
that we use to generate an HTML string of the rendered Markdown content:
1const getHtml = (): string => {
2 if (!props.contentHtml) {
3 return '<p>Oops, here should be some content....</p>'
4 }
5
6 const allCSS = [...document.styleSheets]
7 .map((styleSheet) => {
8 try {
9 return [...styleSheet.cssRules].map((rule) => rule.cssText).join('')
10 } catch (e) {
11 console.log('Access to stylesheet %s is denied. Ignoring...', styleSheet.href)
12 }
13 })
14 .filter(Boolean)
15 .join('\n')
16
17 return juice(`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
18<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
19<head>
20 <meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
21 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
22 <style>
23 body { padding: 20px; background: white !important; color: black !important; }
24 a { color: black !important }
25 hr { border-color: black !important }
26 h1,h2,h3,h4,h5,h6 { color: black !important }
27 blockquote > p { color: white !important }
28 code { color: white !important }
29 </style>
30 <style>${allCSS}</style>
31</head>
32<body>
33 ${props.contentHtml.replace(/<!--(?:(?!-->)[\s\S])*-->/g, '')}
34</body>
35</html>`)
36}
The allCss
variable collects all CSS stylesheets attached to the current document and joins their textual representation as string. I then use juice to inline all CSS properties into the style
attribute.
Info
Inline CSS styles are a smart approach to style HTML emails.
I use props.contentHtml.replace(/<!--(?:(?!-->)[\s\S])*-->/g, '')
to replace HTML comments from the outerHTML
string that is passed to the component via the contentHtml
property (check again the pages/issues/[...slug].vue
component above). This setup worked well for my content and styles, but you likely need to adjust your implementation.
To schedule an issue, I send a POST request to /api/issue
, which I already explained in the backend section.
Conclusion
I’m delighted with my solution. I’m 100% in control of my content and the emails I send to my subscribers!
It was a lot of hard work to build this thing, but I also learned a lot during this process. I hope this will help me grow my newsletter and keep the costs low for an increasing number of subscribers.
A special thanks to Simon Høiberg and Michael Thiessen that provided the technical inspiration for this solution.
Leave a comment if you have questions or feedback or can provide an alternative solution for such a custom-built newsletter service.
If you liked this article, follow me on Twitter to get notified about new blog posts and more content from me. Alternatively (or additionally), you can subscribe to my weekly Vue newsletter.
Create a Table of Contents With Active States in Nuxt 3
I'm a big fan of a table of contents (ToC) on the side of a blog post page, especially if it is a long article. It helps me gauge the article's length and allows me to navigate between the sections quickly.
Use Shiki to Style Code Blocks in HTML Emails
I recently developed a custom newsletter service using Nuxt 3. One of the main reasons why I developed it on my own was that I wanted to use good-looking code blocks in my emails.