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.
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.
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 keyRESEND_AUDIENCE_ID
- Your audience ID from ResendEMAIL_FROM
- The email address emails will be sent fromEMAIL_REPLY_TO
- Optional reply-to addressREQUIRE_CONFIRMATION
- Whether to require double opt-inAstro 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:
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:
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>
);
};
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.",
});
}
},
}),
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.
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!