📨 Contact Forms in Astro with Server Actions and Resend

Introduction

Having a contact form is almost standard on any corporate website or personal site.

And what does this usually mean? We have a form in the frontend that, upon submission, makes a request to a backend (our own or a third-party) responsible for processing the data and sending an email to a contact account.

So far, so good. But if we work with Astro in SSG (Static Site Generation) mode, things get a bit trickier.

In an Astro SSG project, everything we have is HTML and JS served to the frontend. And nowadays, to send an email, we need an API token or credentials that must be kept in a secure environment (on the server side) to prevent someone from stealing them and sending emails on our behalf.

If we tried to send an email from the client, we’d expose the API token and a malicious user could take advantage of it

The usual option would be to set up our own backend (with Express, Fastify, Laravel, etc.) or rely on external services (like Formspree, Netlify Forms, or Zapier).

With your own backend the API token stays safe on the backend; it doesn’t travel to the browser

This works, but it has several drawbacks:

  • We’re using overkill infrastructure for something as simple as a form.
  • You need to configure and deploy a server or integrate the frontend inside the backend.
  • It involves dealing with CORS, routes, security, and authentication.
  • We add maintenance and complexity to the project.
  • Or we become tied to a provider with its own limitations.

So, isn’t there a simpler way?
Yes: Astro Server Actions.

This feature lets us:

  • Keep our Astro SSG project.
  • Define server-side actions within the same project.
  • Handle forms without spinning up a full backend.

With Server Actions an attacker can’t access the API token because it’s stored on the server side

How it works

Astro offers server adapters that allow you to configure a mini server or serverless functions inside the same project. Among the official ones you’ll find:

  • Node.js: a continuous HTTP server, ideal for VPS, App Service, or Docker.
  • Vercel: serverless functions (each request runs the code in an isolated function).
  • Netlify: similar serverless functions.
  • Cloudflare: workers or pages functions (no Node, very fast and edge-distributed).

When we deploy the project, this mini server / functions process the actions we define in our Astro code.

In short: we have a microserver or serverless functions integrated into the same Astro project. A purely static hosting won’t be enough anymore—you’ll need an environment that supports Node.js or the adapter you chose.

To send emails, we can use a specialized service such as Resend, which offers a modern, secure, and easy-to-integrate API.

Let’s get to work

We’re going to build a complete example: a fully functional contact form using:

By the end you’ll have a working form—no extra frameworks or complex setups.


⚙️ 1. Initial setup

Time to open Visual Studio Code (or your favorite editor) and get to it.

Open a terminal and create a new Astro project:

npm create astro@latest my-contact-form

If you already have an empty folder, you can use npm create astro@latest . to initialize it right there.

Choose the use minimal (empty) template option.

Choose minimal template

Then let it install the dependencies.

It runs npm install for you

If you skip this step, you can always run npm install later.

And initialize a Git repository (optional).

Initialize Git

Navigate to the project directory and start the dev server:

cd my-contact-form
npm run dev

You can also open the folder from Visual Studio Code and run npm run dev directly.

⚙️ 2. Configure Server Actions

We now have our blank project up and running. Let’s configure Server Actions to define server-side actions.

In our case we’ll install the Node.js adapter to avoid being tied to any particular cloud provider (feel free to choose another one depending on your deployment platform). Run the following command from the terminal:

npm install @astrojs/node

Now we need to tell our Astro project to use this adapter. Update the astro.config.mjs configuration file:

// @ts-check
import { defineConfig} from 'astro/config';
+ import node from '@astrojs/node';

// https://astro.build/config
export default defineConfig({
+  adapter: node({
+    mode: 'standalone',
+  }),
});

The standalone mode creates an executable server. If you prefer to integrate it into an existing Node.js server, use middleware mode.

✉️ 3. Install Resend

There are many options and platforms for sending emails—Sendgrid, Postmark, Mailgun, Amazon SES, etc.

In this example we’ll use Resend, a modern alternative with a simple API and a free plan of up to 1,000 emails per day.

A quick summary of the steps we’ll follow:

  • If you don’t have one already, create an account and get your API key so you can send emails (for the example that’s enough; if you want to send from your own domain, you’ll need to verify it, etc.).
  • Install Resend’s official SDK.
  • Add the required environment variables.
  • Create the form in Astro.
  • Create a server action that will be responsible for sending the email.
  • Connect the form’s POST or submit event with the action.

Let’s do it. In the terminal, install the official SDK:

npm install resend

Now let’s create a .env file at the project root to store the required environment variables—in this case:

  • Your account’s Resend API key (if you create a free account, you can use the one they provide).
  • The email address that will send the messages (it must be registered as a Domain in Resend—for a quick test you can use onboarding@resend.dev).
  • The email address that will receive the messages (use your own email for testing; if you don’t see the email, check your spam folder).

.env

RESEND_API_KEY=your_api_key_here
FROM_EMAIL=your_verified_email_here
TO_EMAIL=destination_email_here

In this example, FROM_EMAIL is the address that will send the messages (it must be registered as a Domain in Resend). For a quick test you can use onboarding@resend.dev.

Resend provides the API key when you create your account, but be careful—you must copy it locally and you won’t be able to see it again. If you lose it, you’ll need to generate a new one. If you need to generate a new key, the process is simple: go to the API Keys section in your Resend account and create a new one.

How to get a new API Key in Resend

To use these environment variables in our Astro project (with proper typing, and clearly separated between client and server), let’s add the configuration in astro.config.mjs:

- import { defineConfig } from 'astro/config';
+ import { defineConfig, envField } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({
    mode: "standalone",
  }),
+  env: {
+    schema: {
+      RESEND_API_KEY: envField.string({
+        context: 'server',
+        access: 'secret',
+      }),
+      FROM_EMAIL: envField.string({
+        context: 'server',
+        access: 'secret',
+      }),
+      TO_EMAIL: envField.string({
+        context: 'server',
+        access: 'secret',
+      }),
+    },
+  },
});

Here we indicate that these environment variables are server-side (context: 'server') and secret. To import these variables from an .astro file, we first need to compile the project so Astro generates types for them.

Run a build so the types for these environment variables are generated:

npm run build

📝 4. Create the form in Astro

Create a simple form in src/pages/index.astro with name, email, and message fields.

Simple contact form

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>Astro</title>
  </head>
  <body>
    <h1>Astro</h1>
    <h1>Astro</h1>
+    <form method="POST">
+      <input name="name" type="text" placeholder="Name" required />
+      <input name="email" type="email" placeholder="Email" required />
+      <textarea name="message" placeholder="Message"></textarea>
+      <button type="submit"> Send </button>
+    </form>
  </body>
</html>

+<style>
+  body {
+    font-family: system-ui, sans-serif;
+  }
+  form {
+   margin-left: auto;
+    margin-right: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+    max-width: 500px;
+    margin-top: 20px;
+
+    input {
+      padding: 10px;
+      font-size: 16px;
+      border: 1px solid #ccc;
+      border-radius: 4px;
+    }
+
+    textarea {
+      font-family: inherit;
+      padding: 10px;
+      font-size: 16px;
+      border: 1px solid #ccc;
+      border-radius: 4px;
+      min-height: 100px;
+    }
+
+    button {
+      padding: 10px;
+      font-size: 16px;
+      background-color: #007bff;
+      color: white;
+      border: none;
+      border-radius: 4px;
+      cursor: pointer;
+    }
+
+    button:hover {
+      background-color: #0056b3;
+    }
+  }
+</style>

This is a simple form with basic styles. We’ll add functionality later.

🛠️ 5. Create the server action

Back to the backend side: time to define the action that will be executed when the form is submitted.

Astro favors convention over configuration, so server actions are defined in files inside the src/actions folder. Under that folder, create an index.ts file to define our action that sends the email (you can define more than one action in this file):

src/actions/index.ts

import { Resend } from "resend";
import { defineAction } from "astro:actions";
import { RESEND_API_KEY, FROM_EMAIL, TO_EMAIL } from "astro:env/server";
import { z } from "astro:schema";

const resend = new Resend(RESEND_API_KEY);

export const server = {
  sendMail: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().min(1, "Name is required"),
      email: z.string().email("Invalid email"),
      message: z.string().optional(),
    }),
    handler: async (input) => {
      try {
        const { name, email, message = "" } = input;

        await resend.emails.send({
          from: FROM_EMAIL,
          to: TO_EMAIL,
          subject: `New contact message from ${name}`,
          html: `
            <h2>New contact message</h2>
            <p><strong>Name:</strong> ${name}</p>
            <p><strong>Email:</strong> ${email}</p>
            <h3>Message:</h3>
            <p>${message.replace(/\n/g, "<br>")}</p>
          `,
        });

        return { success: true, message: "E-mail sent successfully ✅" };
      } catch (error) {
        return {
          success: false,
          message: "There was an error sending the e-mail ❌",
        };
      }
    },
  }),
};

What’s happening here?

  • We instantiate the Resend client with the API key defined in our environment variables. Notice the import of the env vars uses astro:env/server. If we tried to import this in a script that runs on the client, it would fail (you’d see net::ERR_ABORTED 500 (Internal Server Error) in the browser console). This is very helpful to prevent security mistakes.
  • We define a server object with the actions we want to expose and export it.
  • In this case, the server object has an action named sendMail.
  • In that action we specify:
    • We’ll read data from a form.
    • We add basic validation with zod (already integrated in Astro): name is required, email must be valid, and message is optional.
    • In the action handler, we extract the form data and use the Resend client to send the email.

One important note: we should compile the project again (or if npm run dev is already running in watch mode, it will rebuild on save), because Astro will once again generate type definitions for this file to make consuming it more comfortable.

6. Connect the form with the action

Now go back to src/pages/index.astro and connect the form to the action we just created:

  • Give the form an id so we can reference it from JavaScript.
  • Set the method to POST.
  • Add a script that captures the form’s submit event. Here’s what it does:
    • Prevents the default form behavior (so it doesn’t do a full-page POST on submit).
    • Gathers the form data.
    • Invokes the server action we defined earlier to send the email.
    • Adds an await to wait for the server call result. If it succeeds, it clears the form (we could also display a success message).

-     <form method="POST" class="grid gap-4">
+     <form id="contact-form" method="POST" class="grid gap-4">
        ... same form content ...
      </form>
    </body>
  </html>
+ <script>
+  import { actions } from 'astro:actions';
+  const form = document.getElementById('contact-form');
+  export const handleSubmit = async (event: Event) => {
+    event.preventDefault();
+    const form = event.target as HTMLFormElement;
+    const sendFormData = new FormData(form);
+    const result = await actions.sendMail(sendFormData);
+    if (result.data?.success) {
+      form.reset();
+    }
+  };
+  if (form) {
+    form.addEventListener('submit', handleSubmit);
+  }
+ </script>

<style>

This is a basic example. If you want to go further, you could improve the UX by showing success or error messages, or validating the form with JavaScript.

We opted to handle the form submission with JavaScript because this form is usually inside a component, and it’s simpler to keep all the logic in one place. This way, we also keep the site fully static.

Wanna try it?

npm run dev

Pure HTML alternative

If we wanted, we could do it directly in HTML and run actions based on the Server Action result. However, in that case the entire page would be processed on the server. Also, to redirect to another page using Astro.redirect(), the action must be handled from an Astro page. You can see the difference if you build after each example.

Here’s an example of sending the form without relying on JavaScript and using the action directly in HTML (note: this page would stop being static and become SSR):

index.astro

---
import { actions } from 'astro:actions';
import { ClientRouter } from 'astro:transitions';

export const prerender = false; // Needed to use server actions in HTML

const result = Astro.getActionResult(actions.sendMail);
if (result?.data?.success) {
  return Astro.redirect('/');
} else if (result?.error) {
  console.error('Error sending email:', result.error);
}
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>Astro</title>
    <ClientRouter />
  </head>
  <body>
    <h1>Astro</h1>

    <form id="contact-form" method="POST" action={actions.sendMail}>
      <input name="name" type="text" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message"></textarea>
      <button type="submit"> Send </button>
    </form>
  </body>
</html>

<style>
  form {
    margin-left: auto;
    margin-right: auto;
    display: flex;
    flex-direction: column;
    gap: 20px;
    max-width: 500px;
    margin-top: 20px;

    input {
      padding: 10px;
      font-size: 16px;
      border: 1px solid #ccc;
      border-radius: 4px;
    }

    textarea {
      padding: 10px;
      font-size: 16px;
      border: 1px solid #ccc;
      border-radius: 4px;
      min-height: 100px;
    }

    button {
      padding: 10px;
      font-size: 16px;
      background-color: #007bff;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }

    button:hover {
      background-color: #0056b3;
    }
  }
</style>

🚀 Conclusions

Astro’s Server Actions are ideal when you want to add a small server-side logic layer to an SSG or SSR project without building a full backend.

In summary:

  • You gain flexibility.
  • You keep your project lightweight.
  • You don’t compromise the security of your keys.

You can check the complete example code on GitHub:
👉 Example repository

Before running it, remember to configure your .env file at the project root.

Published by the Content Island team

Content Island — a headless CMS to create, manage, and connect your content.