Jamie Barton
24 JUL 2025

Build Your Own Newsletter Service with Resend

This website runs on Astro, and like many of my other projects, I’ve built a custom newsletter system rather than reaching for a bespoke newsletter third-party service. Here’s why and how I did it.

Why build your own newsletter service?

Most newsletter services either cost too much for small lists or come with limitations that don’t fit how I want to engage with readers. I wanted something lightweight, cost-effective, and completely under my control.

With Resend’s generous free tier and clean API, building your own newsletter service is surprisingly straightforward. Plus, you get exactly the experience you want without vendor lock-in.

What we’re using

  • Astro Actions for server-side form handling
  • Resend for email delivery and contact management
  • React Email for beautiful, responsive email templates

Setting up the project

First, configure your Astro project with the necessary environment variables inside astro.config.mjs:

import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";
import icon from "astro-icon";

import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  integrations: [icon()],
  output: "server",
  adapter: vercel(),
  env: {
    schema: {
      RESEND_API_KEY: envField.string({ context: "server", access: "secret" }),
      RESEND_AUDIENCE_ID: envField.string({
        context: "server",
        access: "secret",
      }),
      REQUIRE_CONFIRMATION: envField.boolean({
        context: "server",
        access: "public",
        default: true,
      }),
      EMAIL_FROM: envField.string({ context: "server", access: "public" }),
      EMAIL_REPLY_TO: envField.string({
        context: "server",
        access: "public",
        optional: true,
      }),
    },
  },
  vite: {
    plugins: [tailwindcss()],
  },
});

You’ll need these environment variables:

  • RESEND_API_KEY - Your Resend API key
  • RESEND_AUDIENCE_ID - Your audience ID from Resend
  • EMAIL_FROM - The email address emails will be sent from
  • EMAIL_REPLY_TO - Optional reply-to address
  • REQUIRE_CONFIRMATION - Whether to require double opt-in

The newsletter action

Astro Actions handle the server-side logic beautifully. Here’s the core subscription action inside actions/index.ts:

import { defineAction, ActionError } from "astro:actions";
import { z } from "astro:schema";
import { Resend } from "resend";
import {
  RESEND_API_KEY,
  RESEND_AUDIENCE_ID,
  REQUIRE_CONFIRMATION,
  EMAIL_FROM,
  EMAIL_REPLY_TO,
} from "astro:env/server";
import { ConfirmationEmail } from "../emails/ConfirmationEmail";
import { render } from "@react-email/render";

const resend = new Resend(RESEND_API_KEY);

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.string().email("Please enter a valid email address"),
      firstName: z.string().min(1, "First name is required"),
      lastName: z.string().optional(),
    }),
    handler: async (input, context) => {
      const { site } = context;

      try {
        // Create contact in Resend as unsubscribed initially
        const contact = await resend.contacts.create({
          email: input.email,
          firstName: input.firstName,
          lastName: input.lastName || "",
          unsubscribed: REQUIRE_CONFIRMATION,
          audienceId: RESEND_AUDIENCE_ID,
        });

        if (contact.error) {
          throw new ActionError({
            code: "BAD_REQUEST",
            message: contact.error.message || "Failed to create contact",
          });
        }

        // If confirmation is required, send confirmation email
        if (REQUIRE_CONFIRMATION) {
          const confirmationUrl = `${site}/confirm?contactId=${contact.data?.id}`;

          const emailResult = await resend.emails.send({
            from: EMAIL_FROM,
            to: input.email,
            replyTo: EMAIL_REPLY_TO || EMAIL_FROM,
            subject: "Please confirm your newsletter subscription",
            html: await render(
              ConfirmationEmail({
                firstName: input.firstName,
                confirmationUrl,
              }),
            ),
          });

          if (emailResult.error) {
            // Try to clean up the contact if email fails
            await resend.contacts.remove({
              id: contact.data?.id || "",
              audienceId: RESEND_AUDIENCE_ID,
            });

            throw new ActionError({
              code: "INTERNAL_SERVER_ERROR",
              message: "Failed to send confirmation email",
            });
          }

          return {
            success: true,
            message: "Please check your email to confirm your subscription.",
            requiresConfirmation: true,
          };
        }

        return {
          success: true,
          message: "Successfully subscribed to the newsletter!",
          requiresConfirmation: false,
        };
      } catch (error) {
        if (error instanceof ActionError) {
          throw error;
        }

        // Handle duplicate email error
        if (
          error instanceof Error &&
          error.message.includes("already exists")
        ) {
          throw new ActionError({
            code: "CONFLICT",
            message: "This email is already subscribed to our newsletter.",
          });
        }

        throw new ActionError({
          code: "INTERNAL_SERVER_ERROR",
          message: "An unexpected error occurred. Please try again.",
        });
      }
    },
  }),
};

This action:

  • Validates the email and name with Zod
  • Creates a contact in Resend
  • Handles errors gracefully
  • Sends confirmation emails when required

The subscription form

The form component handles both server-side and client-side interactions inside NewsletterForm.astro:

---
import { REQUIRE_CONFIRMATION } from "astro:env/server";
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.subscribe);
const isSuccess = result && !result.error;
const inputErrors = isInputError(result?.error) ? result.error.fields : {};
---

<div id="newsletter-container">
  {
    isSuccess ? (
      <div id="success-message">
        <p class="text-[var(--foreground)] font-semibold text-lg">
          You're now subscribed to our newsletter!
        </p>
      </div>
    ) : (
      <div id="newsletter-form">
        <div
          id="error-message"
          class="hidden p-4 bg-red-50 border border-red-200 rounded-md text-red-800 mb-4"
        >
          <p id="error-text" />
        </div>

        {result?.error && !isInputError(result.error) && (
          <div class="p-4 bg-red-50 border border-red-200 rounded-md text-red-800 mb-4">
            <p>
              {result.error.message ||
                "Something went wrong. Please try again."}
            </p>
          </div>
        )}

        <form
          method="POST"
          action={actions.subscribe}
          id="newsletter-form-element"
        >
          <div class="mb-6">
            <label for="firstName" class="block mb-2 text-[var(--primary)]">
              First Name *
            </label>
            <input
              type="text"
              name="firstName"
              id="firstName"
              placeholder="John"
              required
              class:list={[
                "bg-[var(--background-accent)] w-full px-5 py-2.5 border border-[var(--input-border)] rounded-full text-base transition-all duration-200 focus:outline-none focus:border-[var(--border-accent)]",
                { "border-red-400": inputErrors.firstName },
              ]}
            />
            {inputErrors.firstName && (
              <div class="text-red-400 text-sm mt-1">
                {inputErrors.firstName.join(", ")}
              </div>
            )}
          </div>

          <div class="mb-6">
            <label for="email" class="block mb-2 text-[var(--primary)]">
              Email Address *
            </label>
            <input
              type="email"
              name="email"
              id="email"
              placeholder="your@email.com"
              required
              class:list={[
                "bg-[var(--background-accent)] w-full px-5 py-2.5 border border-[var(--input-border)] rounded-full text-base transition-all duration-200 focus:outline-none focus:border-[var(--border-accent)]",
                { "border-red-400": inputErrors.email },
              ]}
            />
            {inputErrors.email && (
              <div class="text-red-400 text-sm mt-1">
                {inputErrors.email.join(", ")}
              </div>
            )}
          </div>

          <button
            type="submit"
            id="submit-button"
            class="w-full py-2.5 bg-[var(--foreground)] text-[var(--background)] border-none rounded-full text-base font-medium cursor-pointer transition-colors duration-200 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
          >
            <span id="button-text">Subscribe to Newsletter</span>
            <div id="loading-spinner" class="hidden">
              <svg
                width="16"
                height="16"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                class="animate-spin"
              >
                <circle class="opacity-25" cx="12" cy="12" r="10" />
                <path
                  class="opacity-75"
                  fill="currentColor"
                  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                />
              </svg>
            </div>
          </button>

          {REQUIRE_CONFIRMATION && (
            <p class="mt-3 text-sm text-[var(--secondary)] text-center">
              You'll receive a confirmation email to verify your subscription.
            </p>
          )}
        </form>
      </div>
    )
  }
</div>

The form includes:

  • Server-side validation with error display
  • Progressive enhancement with JavaScript
  • Loading states and user feedback
  • Responsive design with CSS custom properties

Email template with React Email

React Email makes creating beautiful, responsive emails straightforward. Here’s the ConfirmationEmail.tsx component:

import React from "react";
import {
  Html,
  Head,
  Body,
  Container,
  Section,
  Text,
  Link,
  Button,
  Hr,
  Heading,
} from "@react-email/components";

interface ConfirmationEmailProps {
  firstName: string;
  confirmationUrl: string;
}

export const ConfirmationEmail = ({
  firstName,
  confirmationUrl,
}: ConfirmationEmailProps) => {
  return (
    <Html>
      <Head />
      <Body style={main}>
        <Container style={container}>
          <Section style={section}>
            <Heading style={h1}>Confirm Your Subscription</Heading>

            <Text style={text}>Hi {firstName},</Text>

            <Text style={text}>
              Thank you for subscribing to our newsletter! To complete your
              subscription, please click the button below to confirm your email
              address.
            </Text>

            <Section style={buttonContainer}>
              <Button style={button} href={confirmationUrl}>
                Confirm Subscription
              </Button>
            </Section>

            <Text style={text}>
              If the button above doesn't work, you can also copy and paste the
              following link into your browser:
            </Text>

            <Text style={linkText}>
              <Link href={confirmationUrl} style={link}>
                {confirmationUrl}
              </Link>
            </Text>

            <Hr style={hr} />

            <Text style={footer}>
              If you didn't sign up for this newsletter, you can safely ignore
              this email.
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  );
};

Adding double opt-in confirmation

For better deliverability and compliance, add a confirmation page to handle double opt-in src/pages/confirm.astro:

---
import { actions } from "astro:actions";
import Layout from "../layouts/Layout.astro";

const contactId = Astro.url.searchParams.get("contactId");

let result: any = null;
let error: string | null = null;

if (contactId) {
  try {
    result = await Astro.callAction(actions.confirmSubscription, { contactId });
  } catch (err) {
    error = "An unexpected error occurred. Please try again.";
  }
} else {
  error = "Invalid confirmation link.";
}
---

<Layout title="Newsletter Confirmation">
  <div class="max-w-lg mx-auto p-6">
    {
      result && !result.error ? (
        <div class="text-green-600">
          <h1 class="text-4xl font-semibold mb-4">
            Subscription Confirmed!
          </h1>
          <p class="mb-8">{result.data.message}</p>
        </div>
      ) : (
        <div class="text-red-600">
          <h1 class="text-4xl font-semibold mb-4">
            Confirmation Failed
          </h1>
          <p class="mb-8">
            {error || result?.error?.message || "Something went wrong."}
          </p>
        </div>
      )
    }

    <a
      href="/"
      class="inline-block w-full py-2.5 bg-black text-white text-center rounded-full"
    >
      Return to Homepage
    </a>
  </div>
</Layout>

And add the confirmation action to your src/actions/index.ts file:

confirmSubscription: defineAction({
  input: z.object({
    contactId: z.string().min(1, "Contact ID is required"),
  }),
  handler: async (input) => {
    try {
      // Update contact to subscribed
      const result = await resend.contacts.update({
        id: input.contactId,
        audienceId: RESEND_AUDIENCE_ID,
        unsubscribed: false,
      });

      if (result.error) {
        throw new ActionError({
          code: "BAD_REQUEST",
          message: "Invalid confirmation link or contact not found.",
        });
      }

      return {
        success: true,
        message:
          "Your subscription has been confirmed! Welcome to our newsletter.",
      };
    } catch (error) {
      if (error instanceof ActionError) {
        throw error;
      }

      throw new ActionError({
        code: "INTERNAL_SERVER_ERROR",
        message: "Failed to confirm subscription. Please try again.",
      });
    }
  },
}),

Using your newsletter form

Drop the NewsletterForm.astro component anywhere in your Astro pages:

---
import Layout from "../layouts/Layout.astro";
import NewsletterForm from "../components/NewsletterForm.astro";
---

<Layout title="Subscribe">
  <h1>Join my newsletter</h1>
  <NewsletterForm />
</Layout>

That’s it. You now have a complete newsletter service that costs practically nothing to run, if you’re like me have no subscribers.

The full code can be found on GitHub.

Sending newsletter

Once you’re ready to start sending newsletters to your subscribers, you can open Resend and send a new Broadcast which comes with a beautiful WYSIWYG editor!