Javascript is required
·
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
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:

server/api/subscribe.post.ts
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:

server/api/subscribe.post.ts
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:

server/api/subscribe.post.ts
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:

server/api/subscribe.post.ts
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:

server/api/email-verification.ts
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:

  • 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
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:

server/api/issue.post.ts
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:

yamlserverless/serverless.yml
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:

serverless/publish.js
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:

  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
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 :

pages/issues/[...slug
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:

components/IssueAdminControls.vue
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.

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

The 10 Favorite Features of My Developer Portfolio Website Image

The 10 Favorite Features of My Developer Portfolio Website