·
14 min read

How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES

How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES Image

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 integer
  • created_at: timestamp indicating when the user was added to the table
  • email: email address that should receive the newsletter
  • verification_token: a UUID used for the confirmation email
  • unsubscribe_token: a UUID used for the unsubscribe mechanism
  • verified: 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 integer
  • issue_id: the ID of the newsletter issue
  • html: the html string that should be sent to the subscribers in the body of the email
  • title: a string which will is used as a subject in the emails sent to the subscribers
  • scheduled_at: timestamp when the issue should or has been published
  • published: A boolean indicating if the issue is published
  • send_count: A number that stores how many subscribers the issue has been sent to
  • beacon_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:

lib/ses-client.ts
import nodemailer from 'nodemailer' import aws from '@aws-sdk/client-ses' export const sendEmail = async (fromAddress: string, toAddress: string, subject: string, bodyHtml: string) => { const config = useRuntimeConfig() const ses = new aws.SES({ region: 'eu-central-1', credentials: { accessKeyId: config.MY_AWS_ACCESS_KEY_ID, secretAccessKey: config.MY_AWS_SECRET_ACCESS_KEY, }, }) const transporter = nodemailer.createTransport({ SES: { ses, aws }, sendingRate: 14, // max 14 messages/second }) return transporter.sendMail({ from: fromAddress, to: toAddress, subject, html: bodyHtml }) }

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:

server/api/subscribe.post.ts
import { serverSupabaseServiceRole } from '#supabase/server' import { v4 as uuidv4 } from 'uuid' import * as EmailValidator from 'email-validator' import { sendEmail } from '~/lib/ses-client' export default defineEventHandler(async (event) => { const client = serverSupabaseServiceRole(event) const body = await readBody(event) const { email } = body if (!email) { console.error('Email is required') return { error: 'Email is required' } } if (!EmailValidator.validate(email)) { console.error(`Email ${email} is invalid`) return { error: `Email ${email} is invalid` } } try { const verificationToken = uuidv4() const { error: insertError } = await client .from('newsletter-subscribers') .insert({ email, verification_token: verificationToken, unsubscribe_token: uuidv4() }) if (insertError) { console.error('Failed to insert subscriber', insertError) if (insertError.code === '23505') { return { error: 'You are already subscribed with this email.' } } return { error: insertError } } const html = ` <div style="padding: 20px; display: flex; flex-direction: column; gap: 20px;"> <p>Hey, thanks for signing up for my weekly Vue newsletter!</p> <p>Before I can send you any more emails though, I need you to confirm your subscription by clicking this link:</p> <a href="https://weekly-vue.news/confirm-subscription?token=${verificationToken}" target="_blank" rel="noopener">👉 Confirm subscription​</a> </div> ` return await sendEmail(email, 'Confirm registration', html) } catch (e) { console.error('Failed to send email.', e) return { error: e } } })

Let's analyze the above code. The first step is to validate the email and return an error if it is missing or invalid:

server/api/subscribe.post.ts
import * as EmailValidator from 'email-validator' if (!email) { console.error('Email is required') return { error: 'Email is required' } } if (!EmailValidator.validate(email)) { console.error(`Email ${email} is invalid`) return { error: `Email ${email} is invalid` } }

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:

server/api/subscribe.post.ts
const verificationToken = uuid4() const { error: insertError } = await client .from('newsletter-subscribers') .insert({ email, verification_token: verificationToken, unsubscribe_token: uuidv4() }) if (insertError) { console.error('Failed to insert subscriber', insertError) if (insertError.code === '23505') { return { error: 'You are already subscribed with this email.' } } return { error: insertError } }

Finally, we send the confirmation mail that contains a link with the generated verificationToken as query parameter:

server/api/subscribe.post.ts
const html = ` <div style="padding: 20px; display: flex; flex-direction: column; gap: 20px;"> <p>Hey, thanks for signing up for my weekly Vue newsletter!</p> <p>Before I can send you any more emails though, I need you to confirm your subscription by clicking this link:</p> <a href="https://weekly-vue.news/confirm-subscription?token=${verificationToken}" target="_blank" rel="noopener">👉 Confirm subscription​</a> </div> ` return await sendEmail(email, 'Confirm registration', html)

Clicking on this link in the frontend will trigger the following backend endpoint:

server/api/email-verification.ts
import { serverSupabaseServiceRole } from '#supabase/server' export default defineEventHandler(async (event) => { const client = serverSupabaseServiceRole(event) const query = getQuery(event) const { token } = query if (!token) { return { error: 'Verification token is missing' } } const { data: subscriberData, error: selectError } = await client .from('newsletter-subscribers') .select() .eq('verification_token', token) if (selectError) { console.error('Failed to confirm subscription', selectError) return { error: selectError.details } } else { const { error: updateError } = await client .from('newsletter-subscribers') .update({ verified: true }) .eq('verification_token', token) if (updateError) { console.error('Update error', updateError) return { error: updateError } } return { error: null } } })

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:

  • Ensuring compliance with the General Data Protection Regulation (GDPR): The GDPR requires that you obtain explicit consent from users before adding them to your newsletter subscriber list and processing their personal data, such as their email address.
  • Ensuring that your newsletter subscribers are actively engaged: By requiring confirmation of subscription, you can ensure that only users who actively want to receive your newsletters will be added to your subscriber list. This can help prevent accidental or unwanted subscriptions and improve the quality of your subscriber list.
  • Maintaining a clean and accurate contact list: By requiring confirmation of subscription, you can ensure that only those users who are truly interested in receiving your company updates will be added to your subscriber list. This can help you maintain a high-quality list of engaged and interested contacts.

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:

server/api/unsubscribe.post.ts
import { serverSupabaseServiceRole } from '#supabase/server' export default defineEventHandler(async (event) => { const client = serverSupabaseServiceRole(event) const body = await readBody(event) const { token } = body if (!token) { console.error('Token is required') return { error: 'Token is required' } } try { const { error: deleteError, data } = await client .from('newsletter-subscribers') .delete() .eq('unsubscribe_token', token) if (deleteError) { console.error(`Failed to unsubscribe "${token}"`, deleteError) return { error: deleteError } } return { message: `Successfully unsubscribed "${token}"` } } catch (e) { console.error(`Failed to unsubscribe "${token}"`, e) return { error: e } } })

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:

server/api/issue.post.ts
import { serverSupabaseServiceRole } from '#supabase/server' import { Database } from '~/types/supabase' export default defineEventHandler(async (event) => { const client = serverSupabaseServiceRole<Database>(event) const body = await readBody(event) const { issueId, html, title, scheduleDate } = body if (!issueId || !html || !title || !scheduleDate) { return { error: `Parameter missing, required are [issueId, html, title, scheduleDate]. Received: ${JSON.stringify(body)}`, } } const { error: insertIssueError } = await client.from('newsletter-issues').upsert( { issue_id: issueId, html: html, title: title, published: false, scheduled_at: scheduleDate, }, { onConflict: 'issue_id' } ) if (insertIssueError) { console.error('Failed to upsert issue', insertIssueError) return { error: 'Failed to upsert issue' } } return { error: null } })

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:

yamlserverless/serverless.yml
org: org app: app service: service frameworkVersion: '3' provider: name: aws region: eu-central-1 runtime: nodejs14.x environment: SUPABASE_URL: ${ssm:secret-supabase-url} SUPABASE_SERVICE_KEY: ${ssm:secret-supabase-service-key} functions: publish: handler: publish.run timeout: 900 events: - http: method: 'POST' path: /newsletter/publish async: true cors: true # Invoke Lambda function weekly on Monday at 3pm - 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:

serverless/publish.js
'use strict' const supabase = require('@supabase/supabase-js') const aws = require('aws-sdk') const nodemailer = require('nodemailer') const FROM_ADDRESS = 'newsletter@weekly-vue.news' module.exports.run = async (event, context) => { let requestBody = event if (event.body) { try { requestBody = JSON.parse(event.body) } catch (error) { requestBody = event.body } } const time = new Date() console.log(`Cron function "${context.functionName}" ran at ${time} with event ${JSON.stringify(requestBody)}`) const supabaseClient = supabase.createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_KEY) const ses = new aws.SES() const transporter = nodemailer.createTransport({ SES: { ses, aws }, sendingRate: 14, // max 14 messages/second }) // get verified subscribers const { data: subscriberData, error: selectError } = await supabaseClient .from('newsletter-subscribers') .select() .eq('verified', true) // get stored issue that haven't been published let { data: unpublishedNewsletterIssues, error: selectIssuesError } = await supabaseClient .from('newsletter-issues') .select() .eq('published', false) /** ⚠️☠️ SENDING MAILS TO ALL SUBSCRIBERS ⚠️☠️ */ // find scheduled issue const todayDate = new Date() const issueScheduledForToday = unpublishedNewsletterIssues.find((issue) => { const issueScheduleDate = new Date(issue.scheduled_at) return ( todayDate.getFullYear() === issueScheduleDate.getFullYear() && todayDate.getMonth() === issueScheduleDate.getMonth() && todayDate.getDate() === issueScheduleDate.getDate() ) }) if (!issueScheduledForToday) { return { statusCode: 400, body: JSON.stringify({ message: `Found no unpublished issue that is scheduled for today: ${JSON.stringify( unpublishedNewsletterIssues )}`, }), } } else { const emailValues = await Promise.allSettled( subscriberData.map(async (subscriber) => { const { email, unsubscribe_token } = subscriber return transporter.sendMail({ from: FROM_ADDRESS, to: email, subject: issueScheduledForToday.title, html: issueScheduledForToday.html, }) }) ) const successEmails = emailValues.filter((v) => v.status === 'fulfilled') const failedEmails = emailValues.filter((v) => v.status === 'rejected') console.log('Result sending emails', { successEmails: successEmails.length, failedEmails: failedEmails.length }) // update published status const { error: updatePublishedError } = await supabaseClient .from('newsletter-issues') .update({ published: true, send_count: successEmails.length, }) .eq('issue_id', issueScheduledForToday.issue_id) if (updatePublishedError) { console.error('Failed to update published status', updatePublishedError) } return { statusCode: 200, body: JSON.stringify({ total: subscriberData.length, success: successEmails.length, failures: failedEmails.length, }), } } }

A lot is going on in this function; let's break it down:

  1. Initialize Supabase & Nodemailer clients
  2. Get all verified subscribers
  3. Get all stored issues that haven't been published yet
  4. Find the unpublished issue which schedule_at timestamp is today
  5. Send this issue to all verified subscribers
  6. Set published to true and update send_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:

content/issues/3.md
--- title: 'Weekly Vue News #3 - Any Tip' date: '2023-01-02T13:00:00.231Z' id: 3 --- :issue-header Hi 👋 Have a nice week ☀️ :divider ## Vue Tip: Any Tip ## Curated Vue Content ::external-link{url="https://github.com/RomanHotsiy/commitgpt" title="🛠️ commitgpt"} 👉🏻 Automatically generate commit messages using ChatGPT. :: ## Quote of the week ## JavaScript Tip: Any Tip ## Curated Web Development Content

These files are rendered on a Nuxt page using <ContentRenderer> from Nuxt Content :

pages/issues/[...slug
<script setup lang="ts"> const route = useRoute() const user = useSupabaseUser() const content = ref(null) const { data } = await useAsyncData('issue', () => queryContent(route.path).findOne()) </script> <template> <main> <IssueAdminControls v-if="user" :issue="data" :content-html="content?.outerHTML" /> <div v-if="data" ref="content"> <ContentDoc v-slot="{ doc }"> <h1>{{ doc.title }}</h1> <ContentRenderer :value="doc" /> </ContentDoc> </div> <div v-else> <h1>Not Found</h1> <div class="flex justify-center"> <NuxtLink to="/issues">Browse issues</NuxtLink> </div> </div> </main> </template>

Using Supabase Auth I provide a way to log in as admin and scheduling issues:

components/IssueAdminControls.vue
<script lang="ts" setup> import { Ref } from 'vue' import juice from 'juice' import { ParsedContent } from '@nuxt/content/dist/runtime/types' const props = withDefaults(defineProps<{ issue: ParsedContent | null; contentHtml?: string }>(), {}) const scheduleDate = ref(null) const { data: issueData, error: issueError, pending } = await useFetch(`/api/issue?issueId=${props.issue?.id}`) const getHtml = (): string => { if (!props.contentHtml) { return '<p>Oops, here should be some content....</p>' } const allCSS = [...document.styleSheets] .map((styleSheet) => { try { return [...styleSheet.cssRules].map((rule) => rule.cssText).join('') } catch (e) { console.log('Access to stylesheet %s is denied. Ignoring...', styleSheet.href) } }) .filter(Boolean) .join('\n') return juice(`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" lang="en"> <head> <meta http-equiv="Content-Type" content="text/html charset=UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <style> body { padding: 20px; background: white !important; color: black !important; } a { color: black !important } hr { border-color: black !important } h1,h2,h3,h4,h5,h6 { color: black !important } blockquote > p { color: white !important } code { color: white !important } </style> <style>${allCSS}</style> </head> <body> ${props.contentHtml.replace(/<!--(?:(?!-->)[\s\S])*-->/g, '')} </body> </html>`) } const schedule = async () => { const { data, error } = await useFetch('/api/issue', { method: 'POST', body: JSON.stringify({ issueId: props.issue.id, html: getHtml(), title: props.issue.title, scheduleDate: scheduleDate.value, }), }) // ... } </script> <template> <div class="p-4 border border-orange-400 rounded-md mb-8"> <h3>Admin Controls</h3> <div class="flex flex-wrap gap-8"> <input v-model="scheduleDate" type="date" class="h-10 overflow-visible text-white" /> <BaseButton :@click="schedule" class="bg-orange-400">Schedule</BaseButton> </div> </div> </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:

const getHtml = (): string => { if (!props.contentHtml) { return '<p>Oops, here should be some content....</p>' } const allCSS = [...document.styleSheets] .map((styleSheet) => { try { return [...styleSheet.cssRules].map((rule) => rule.cssText).join('') } catch (e) { console.log('Access to stylesheet %s is denied. Ignoring...', styleSheet.href) } }) .filter(Boolean) .join('\n') return juice(`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" lang="en"> <head> <meta http-equiv="Content-Type" content="text/html charset=UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <style> body { padding: 20px; background: white !important; color: black !important; } a { color: black !important } hr { border-color: black !important } h1,h2,h3,h4,h5,h6 { color: black !important } blockquote > p { color: white !important } code { color: white !important } </style> <style>${allCSS}</style> </head> <body> ${props.contentHtml.replace(/<!--(?:(?!-->)[\s\S])*-->/g, '')} </body> </html>`) }

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.

I will never share any of your personal data. You can unsubscribe at any time.

If you found this article helpful.You will love these ones as well.
Track Twitter Follower Growth Over Time Using A Serverless Node.js API on AWS Amplify Image

Track Twitter Follower Growth Over Time Using A Serverless Node.js API on AWS Amplify

Create an RSS Feed With Nuxt 3 and Nuxt Content v2 Image

Create an RSS Feed With Nuxt 3 and Nuxt Content v2

Build and Deploy a Serverless GraphQL React App Using AWS Amplify Image

Build and Deploy a Serverless GraphQL React App Using AWS Amplify

Analyze Memory Leaks in Your Nuxt App Image

Analyze Memory Leaks in Your Nuxt App