komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
CMS & Integrations••By Komal Nakrani

Newsletter Signup Forms in Astro

Add a newsletter signup form to Astro: post a plain HTML form to your email provider on a static site, or use an Astro Action to call its API on the server.

#Astro#Forms#Newsletter#Actions

There are two ways to add a newsletter signup to an Astro site. On a static site, post a plain HTML <form> directly to your email provider’s form endpoint; most providers (Buttondown, Kit, Mailchimp, MailerLite and others) give you one. If the site has an adapter, use an Astro Action that validates the email and calls the provider’s API with a secret key, so the key never reaches the browser.

Key Takeaways

  • Static sites: the form’s action is the provider’s hosted endpoint. No JavaScript required.
  • With an adapter: an Action validates input with Zod and calls the API server-side.
  • Never put a secret API key in client code or a PUBLIC_ variable.
  • Add a honeypot field and a clear success state.

Option 1: static form to the provider

Copy the form endpoint from your provider’s embed settings:

---
// src/components/NewsletterForm.astro
const endpoint = 'https://provider.example/subscribe/your-list-id';
---
<form action={endpoint} method="POST" class="newsletter">
  <label for="nl-email">Get new posts by email</label>
  <div class="row">
    <input id="nl-email" type="email" name="email" required autocomplete="email" placeholder="[email protected]" />
    <button type="submit">Subscribe</button>
  </div>
</form>

Field names (email, email_address, EMAIL) and any hidden fields depend on the provider, so match their embed code exactly. After submitting, the provider shows its own confirmation page or redirects to a URL you configure, such as /thanks/.

This works with JavaScript disabled, adds no bundle, and fits any static host.

Submitting without leaving the page

Enhance it with a short script; the plain form remains the fallback:

<p class="nl-status" role="status" aria-live="polite"></p>

<script>
  const form = document.querySelector<HTMLFormElement>('form.newsletter');
  const status = document.querySelector<HTMLElement>('.nl-status');

  form?.addEventListener('submit', async (e) => {
    e.preventDefault();
    const res = await fetch(form.action, { method: 'POST', body: new FormData(form), mode: 'no-cors' });
    form.reset();
    if (status) status.textContent = 'Thanks. Check your inbox to confirm.';
  });
</script>

no-cors is needed when the provider doesn’t send CORS headers, but it means you can’t read the response or detect errors. If you need real error handling, use option 2.

Option 2: an Astro Action

With an adapter installed, define the action:

// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro/zod';
import { NEWSLETTER_API_KEY } from 'astro:env/server';

export const server = {
  subscribe: defineAction({
    accept: 'form',
    input: z.object({
      email: z.email(),
      website: z.string().max(0).optional(), // honeypot
    }),
    handler: async ({ email }) => {
      const res = await fetch('https://api.provider.example/v1/subscribers', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${NEWSLETTER_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email }),
      });
      if (!res.ok) {
        throw new ActionError({ code: 'BAD_REQUEST', message: 'Could not subscribe. Try again later.' });
      }
      return { email };
    },
  }),
};

Declare the key in config so it’s server-only:

// astro.config.mjs
import { defineConfig, envField } from 'astro/config';

export default defineConfig({
  env: {
    schema: {
      NEWSLETTER_API_KEY: envField.string({ context: 'server', access: 'secret' }),
    },
  },
});

Call the action from the form with a small script, so the page itself can stay static:

<form class="newsletter">
  <input type="email" name="email" required />
  <input type="text" name="website" tabindex="-1" autocomplete="off" hidden />
  <button>Subscribe</button>
  <p class="nl-status" role="status" aria-live="polite"></p>
</form>

<script>
  import { actions } from 'astro:actions';

  const form = document.querySelector<HTMLFormElement>('form.newsletter')!;
  const status = form.querySelector<HTMLElement>('.nl-status')!;

  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const { error } = await actions.subscribe(new FormData(form));
    status.textContent = error ? error.message : 'Thanks. Check your inbox to confirm.';
    if (!error) form.reset();
  });
</script>

Invalid emails fail Zod validation before your handler runs, and the error comes back typed.

  • Honeypot: the hidden website field above. Bots fill it; humans don’t. The schema rejects any value.
  • Double opt-in: turn it on in your provider. It protects your sender reputation and is expected in many regions.
  • Say what they’re signing up for, and link your privacy policy next to the button.

For when an adapter is worth adding, see mixing static and on-demand pages and the Astro SSR guide, which covers Actions in more depth. The headless CMS guide covers other third-party services on static sites. For form markup itself, the MDN form element reference covers every attribute.

FAQ

Can I add a newsletter form to a static Astro site?

Yes. Point a plain HTML form’s action at your email provider’s hosted form endpoint. No server or JavaScript is required.

How do I keep my newsletter API key secret in Astro?

Call the API from server code only, in an Action or endpoint, and declare the key with envField.string({ context: 'server', access: 'secret' }).