Astro Actions: Server-Side Email Verification for Content-Heavy Sites

hangrydev ·

Newsletter Forms Are the Reason Your Bounce Rate Climbs

Content sites convert readers to subscribers. That’s the deal. Blog post leads to newsletter, newsletter leads to repeat traffic. But every bad email in your list costs you: bounced sends, suppressed domains, and sender reputation that takes months to rebuild.

Most Astro sites validate email format client-side with a regex and call it done. Then [email protected] and [email protected] sail through, hit your ESP, and bounce. The fix is a server-side check before any of that happens, and Astro Actions are exactly the right place to put it.

What Astro Actions Actually Are

Astro 4.8 shipped Actions as an experimental feature. Astro 4.15 made them stable. They’re type-safe server functions you call from the client or from forms, and they run in Astro’s server runtime rather than in the browser.

Two things make them useful for email validation. First, your API key stays on the server. It never touches the client bundle. Second, Actions support progressive enhancement by default: add a method="POST" form pointing at an action, and it works without JavaScript. When JavaScript loads, the same form submits via fetch without a page reload.

That’s the architecture we’re building. Define the action in src/actions/index.ts, call it from a Svelte component or plain HTML form, and handle the response on the client.

Project Setup

You need Astro 5 with server rendering enabled. Actions don’t run in fully static mode.

npm create astro@latest -- --template minimal
cd my-site
npx astro add node

The node adapter enables server rendering. If you’re deploying to Vercel or Netlify, swap it for the matching adapter. Actions need a server runtime regardless of where you deploy.

Install Zod if it’s not already in your project:

npm install zod

Set your API key in .env:

TRUEMAIL_API_KEY=your_api_key_here

Defining the Action

Actions live in src/actions/index.ts. Export a named object from defineAction() calls. Astro reads this file at build time and generates the type-safe client.

// src/actions/index.ts
import { defineAction } from "astro:actions";
import { z } from "zod";

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z
        .string()
        .min(1, "Email is required")
        .email("That doesn't look like a valid email address"),
      name: z.string().min(1, "Name is required").optional(),
    }),
    handler: async (input) => {
      const { email } = input;

      // Validate deliverability before touching your ESP
      let emailStatus = "unknown";

      try {
        const response = await fetch("https://api.mailcop.net/v1/verify", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${import.meta.env.TRUEMAIL_API_KEY}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ email }),
          signal: AbortSignal.timeout(3000),
        });

        if (response.ok) {
          const result = await response.json();
          emailStatus = result.status;

          if (result.status === "undeliverable") {
            return {
              success: false,
              error: "email",
              message: "That email address doesn't appear to be deliverable.",
            };
          }

          if (result.disposable) {
            return {
              success: false,
              error: "email",
              message: "Disposable email addresses aren't allowed.",
            };
          }
        }
      } catch {
        // Fail open: don't block a real signup because the validation API timed out.
        // The ESP will catch hard bounces on send.
      }

      // Add to your email list (Mailchimp, ConvertKit, Resend, whatever)
      // await addToList(email, input.name);

      return { success: true };
    },
  }),
};

Three things worth pointing out here.

accept: "form" tells Astro this action accepts FormData. Without it, Astro expects JSON and the progressive enhancement path breaks. Form submissions send FormData. JSON submissions come from explicit actions.subscribe() calls with an object. If your form needs to work without JavaScript, you want "form".

import.meta.env.TRUEMAIL_API_KEY is how Astro reads server-only environment variables. Unlike PUBLIC_ prefixed variables, this one never appears in client bundles. Astro strips it out at build time. Your API key doesn’t reach the browser.

The catch block is intentional. If MailCop’s API is slow or returns an error, the action continues and adds the subscriber. You’re blocking on a third-party API call. Don’t make that API a single point of failure for your signup flow. See the email validation API guide for how to think about fail-open vs fail-closed trade-offs.

The Progressive Enhancement Form

Here’s a plain Astro page with a form that works in both modes: full-page submit without JavaScript, fetch-based submit with JavaScript.

---
// src/pages/subscribe.astro
import { actions } from "astro:actions";

const result = Astro.getActionResult(actions.subscribe);
---

<html lang="en">
  <head>
    <title>Subscribe</title>
  </head>
  <body>
    <form method="POST" action={actions.subscribe}>
      <div>
        <label for="email">Email address</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          autocomplete="email"
        />
        {result?.data?.error === "email" && (
          <p class="error" role="alert">{result.data.message}</p>
        )}
      </div>

      <button type="submit">Subscribe</button>

      {result?.data?.success && (
        <p>You're subscribed. Check your inbox for a confirmation.</p>
      )}
    </form>
  </body>
</html>

Astro.getActionResult() reads the action result from the server response on a full-page POST. After a form submission, Astro redirects back to the same page with the result available. This is how you show errors without JavaScript: submit, redirect, render the error from getActionResult().

That URL-based result pattern is why action={actions.subscribe} works. Astro generates the correct form action URL at build time, pointing to the action’s server endpoint. Submit the form, get the result, redirect, render. Thirty years of web forms, working as intended.

The JavaScript-Enhanced Version

Plain forms work everywhere. But if your site loads JavaScript (and it will), you can improve the UX without rewriting anything.

// src/components/SubscribeForm.ts
import { actions, isInputError } from "astro:actions";

const form = document.getElementById("subscribe-form") as HTMLFormElement;
const emailInput = document.getElementById("email") as HTMLInputElement;
const submitButton = form.querySelector("button[type='submit']") as HTMLButtonElement;
const messageDiv = document.getElementById("form-message") as HTMLDivElement;

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  submitButton.disabled = true;
  submitButton.textContent = "Checking...";
  messageDiv.textContent = "";

  const { data, error } = await actions.subscribe(new FormData(form));

  submitButton.disabled = false;
  submitButton.textContent = "Subscribe";

  if (error) {
    if (isInputError(error)) {
      // Zod validation errors: missing or malformed fields
      const emailError = error.fields.email?.[0];
      if (emailError) {
        showFieldError(emailInput, emailError);
      }
    } else {
      messageDiv.textContent = "Something went wrong. Try again.";
    }
    return;
  }

  if (data?.success) {
    form.reset();
    messageDiv.textContent = "You're subscribed!";
  } else if (data?.error === "email") {
    showFieldError(emailInput, data.message);
  }
});

function showFieldError(input: HTMLInputElement, message: string) {
  const existing = input.parentElement?.querySelector(".field-error");
  if (existing) existing.remove();

  const error = document.createElement("p");
  error.className = "field-error";
  error.setAttribute("role", "alert");
  error.textContent = message;
  input.after(error);
}

isInputError() is Astro’s helper for distinguishing Zod validation failures from other errors. An input error means the schema rejected the data before your handler ran. The error.fields object contains per-field error arrays. Match the shape Zod returns, display the message, done.

No JavaScript framework required. This is plain TypeScript that Astro compiles down. It works in any <script> block in a .astro file or as an imported client-side module.

Why Zod Isn’t Enough on Its Own

z.string().email() runs a format check. That’s its job. It’s fast, free, and catches missing @ signs before any network call happens. But format isn’t deliverability.

Which of these pass Zod validation?

All four. Every one of them. Zod sees valid syntax and moves on. That’s correct behavior for a format validator. The problem is stopping there.

MX checks catch dead domains in under 200ms. If a domain has no MX records, no email can be delivered to it. SMTP checks go further and probe actual mailboxes, but they take 500ms to 3 seconds per address. The MX vs SMTP validation accuracy post has data on the trade-offs. For a newsletter form, MX checks in the action and SMTP async is the right split.

For disposable addresses specifically, blocklists are the only reliable approach. New disposable providers spin up weekly. Regex patterns can’t keep up. The disposable email blocklist guide covers how MailCop’s list stays current.

Handling the Response in an Astro Island

If you’re using React or Preact components as Astro Islands, the pattern is slightly different. The actions client works the same way, but you wire it into component state.

// src/components/SubscribeIsland.tsx
import { useState } from "react";
import { actions, isInputError } from "astro:actions";

export function SubscribeIsland() {
  const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
  const [errorMessage, setErrorMessage] = useState("");

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("loading");
    setErrorMessage("");

    const formData = new FormData(event.currentTarget);
    const { data, error } = await actions.subscribe(formData);

    if (error) {
      setStatus("error");
      if (isInputError(error)) {
        setErrorMessage(error.fields.email?.[0] ?? "Check your email address.");
      } else {
        setErrorMessage("Something went wrong. Please try again.");
      }
      return;
    }

    if (data?.success) {
      setStatus("success");
    } else if (data?.error === "email") {
      setStatus("error");
      setErrorMessage(data.message);
    }
  }

  if (status === "success") {
    return <p>You're subscribed. Check your inbox.</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email address</label>
      <input
        id="email"
        name="email"
        type="email"
        required
        autoComplete="email"
      />
      {status === "error" && (
        <p role="alert" className="error">{errorMessage}</p>
      )}
      <button type="submit" disabled={status === "loading"}>
        {status === "loading" ? "Checking..." : "Subscribe"}
      </button>
    </form>
  );
}

Pass client:load to the island directive in your .astro file:

---
import { SubscribeIsland } from "../components/SubscribeIsland";
---

<SubscribeIsland client:load />

The client:load directive hydrates the island immediately. Use client:visible for below-the-fold signup sections to avoid blocking the initial paint. The action call works identically in either case.

What About the Action on Static Pages?

Actions need a server runtime. In Astro 5, the default output: "static" mode supports per-page on-demand rendering as long as you have an adapter installed. You don’t need a separate output mode. Pages that use Actions (or need request-time data) opt into server rendering with export const prerender = false. Everything else stays static.

---
// src/pages/subscribe.astro
export const prerender = false;

import { actions } from "astro:actions";
const result = Astro.getActionResult(actions.subscribe);
---

The subscribe page renders server-side. Everything else stays static. You get CDN performance for blog posts and server-rendered form handling for the one page that needs it.

Comparing to Other Frameworks

Astro Actions and SvelteKit Form Actions solve the same problem: server-side form handling without writing a separate API route. What’s the main difference? The progressive enhancement path.

SvelteKit’s use:enhance intercepts form submissions and patches in the response without a reload. Astro’s equivalent is isInputError() plus manual state management in JavaScript, or a full page redirect via getActionResult(). Neither is wrong, they just reflect different priorities.

The SvelteKit email validation guide walks through that pattern in detail. If you’re picking between frameworks for a content site, Astro’s partial hydration model (only ship JavaScript for interactive islands) gives it a page weight advantage that matters for content-heavy sites with lots of static text.

The Next.js Server Actions approach requires the App Router and useActionState. Heavier setup for a signup form, more powerful for complex multi-step flows. Pick the one that matches your actual use case.

The Full Flow

Here’s what happens end to end when a reader subscribes.

  1. Browser submits the form (plain POST or fetch, depending on JavaScript availability).
  2. Astro routes the request to the subscribe action.
  3. Zod validates the input. Format errors return immediately without hitting the API.
  4. The action calls MailCop’s API with a 3-second timeout.
  5. If the email is undeliverable or disposable, the action returns an error. No ESP call.
  6. If validation passes (or times out and fails open), the subscriber gets added to your list.
  7. The result flows back: getActionResult() on full-page redirects, data from the actions.subscribe() call with JavaScript.

Two API calls total on a bad email: Zod (free, instant) and MailCop (billed, under 400ms). One API call on a good email: same path, different result. Zero API calls on format errors: Zod catches those before any network request.

That’s the right shape for a newsletter form. Fast enough to feel instant, accurate enough to catch the addresses that would hurt your sender reputation.