Framework guide

Astro

Astro ships zero JavaScript by default, so a plain form is a perfect fit. Drop this component onto any page, no SSR or adapters required.

Prefer a component?: Install @shipmyform/astro for a <ShipMyForm> component that upgrades to fetch and emits success/error events, see the JavaScript SDK. The plain-HTML version below needs no dependencies.

An Astro contact form does not need a backend. The form POSTs straight to your ShipMyForm endpoint, so your site stays static: no Astro SSR adapter, no src/pages/api route, and no Astro Action. Every submission is stored, spam-filtered, and forwarded to your connectors. Start with the plain form, then add an inline success state only if you want one.

The plain form

The simplest Astro form ships zero JavaScript. Put a <form> with an action and method="POST" in a component and let the browser submit it. On success the visitor lands on a hosted thank-you page (or your own, see redirects below).

src/components/ContactForm.astro
---
// No server code needed. This page stays fully static.
const action = "https://shipmyform.com/f/frm_8Kx2mQ9pL4vN";
---

<form action={action} method="POST" class="contact">
  <input type="email" name="email" placeholder="[email protected]" required />
  <textarea name="message" placeholder="Your message" required></textarea>
  <input type="text" name="_gotcha" style="display:none" tabindex="-1" />
  <button type="submit">Send</button>
</form>

Then use it on any page:

src/pages/contact.astro
---
import ContactForm from "../components/ContactForm.astro";
---
<h1>Contact us</h1>
<ContactForm />

A success state without a page reload

Want to keep the visitor on the page? Add an inline <script> to the .astro file that intercepts submit, POSTs with fetch() and a FormData body, and sends Accept: application/json. With that header ShipMyForm returns { ok: true } as JSON instead of a redirect, so you can toggle a success message yourself. Astro bundles and inlines the <script> for you, so there is no extra build step.

src/components/ContactForm.astro
---
const action = "https://shipmyform.com/f/frm_8Kx2mQ9pL4vN";
---

<form id="contact" action={action} method="POST">
  <input type="email" name="email" required />
  <textarea name="message" required></textarea>
  <input type="text" name="_gotcha" style="display:none" tabindex="-1" />
  <button type="submit">Send</button>
</form>
<p id="thanks" hidden>Thanks, we&apos;ll be in touch!</p>

<script>
  const form = document.getElementById("contact");
  form?.addEventListener("submit", async (e) => {
    e.preventDefault();
    const res = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    if (res.ok) {
      form.hidden = true;
      document.getElementById("thanks").hidden = false;
    }
  });
</script>

Static vs SSR: you do not need output

Because the form posts directly to ShipMyForm, a fully static Astro build is all you need. You do not set output: "server" in astro.config.mjs, you do not add a platform adapter (Node, Vercel, Netlify, or Cloudflare), and you do not write an src/pages/api route or an Astro Action to receive the submission. ShipMyForm is the backend, so the default static output ships as-is to any host.

astro.config.mjs
import { defineConfig } from "astro/config";

// No 'output: "server"' and no adapter needed for a ShipMyForm contact form.
export default defineConfig({
  // ...your existing config
});

Using a framework island

If your page already uses a React, Vue, Svelte, or Solid island, you can drop the @shipmyform/* SDK into that component instead of wiring up fetch by hand, see the JavaScript SDK. Plain Astro needs no island at all: the inline <script> above is enough. Reach for an island only when you already have one hydrated on the page.

View Transitions gotcha

If you use <ClientRouter />(Astro's view transitions), navigations swap the page without a full reload. An inline <script> that bound its submit handler on first load will not run again after a client-side navigation, so the form quietly stops calling fetch and falls back to a full-page POST. Re-bind the handler on the astro:page-load event, which fires after the initial load and after every view transition.

src/components/ContactForm.astro
<script>
  function wire() {
    const form = document.getElementById("contact");
    if (!form || form.dataset.wired) return;
    form.dataset.wired = "true";
    form.addEventListener("submit", async (e) => {
      e.preventDefault();
      const res = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      if (res.ok) {
        form.hidden = true;
        document.getElementById("thanks").hidden = false;
      }
    });
  }
  document.addEventListener("astro:page-load", wire);
</script>

The data-wired guard stops the handler being attached twice if the script runs again. Event delegation on document (a single listener that checks e.target) is an alternative that survives navigations without re-binding.

File uploads

Add an <input type="file"> with a name and submit as multipart/form-data. In a plain form set enctype="multipart/form-data"; with fetch, a FormData body already sends the right content type and boundary, so uploads work with no code changes. See file uploads for size limits and storage details.

src/components/UploadForm.astro
---
const action = "https://shipmyform.com/f/frm_8Kx2mQ9pL4vN";
---

<form action={action} method="POST" enctype="multipart/form-data">
  <input type="email" name="email" required />
  <input type="file" name="attachment" />
  <button type="submit">Send</button>
</form>

Redirect vs AJAX

The two approaches differ in what happens after submit. A plain form POST triggers a full navigation: the browser leaves the page and lands on a thank-you page. To send visitors to your own page, add a hidden _redirectfield (its URL must match one of the form's allowed domains) or set a default redirect in the form's Settings tab. Set a _subject field to control the email notification subject line.

src/components/ContactForm.astro
<form action={action} method="POST">
  <input type="hidden" name="_redirect" value="https://yoursite.com/thank-you" />
  <input type="hidden" name="_subject" value="New contact from the website" />
  <input type="email" name="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

The fetch approach never reloads. You handle the JSON response yourself and toggle an inline success state, which keeps the visitor on the same page. The _redirect field is ignored when you request JSON, because there is no navigation to redirect.

Spam protection

A honeypot field is handled automatically. Add a hidden _gotcha input (as in the first form above) and bots that fill it are dropped before they reach your inbox. No CAPTCHA, no third-party script, no friction for real people. For the full picture, read how to stop form spam without reCAPTCHA.

Troubleshooting

  • 404 on the endpoint. Double-check the form ID in the URL (/f/frm_...) and make sure the request is a POST. A GET to the endpoint will not match.
  • fetch returns a redirect. If your fetch call gets an opaque or redirected response, add the Accept: application/json header so ShipMyForm returns JSON instead of a cross-origin redirect.
  • Handler stops firing after navigation. With view transitions, re-bind on astro:page-load (see above) or the inline script only runs on the first page load.
  • Fields not showing up. Every input needs a name. Inputs without one are never sent by the browser, including selects, checkboxes, and textareas.
  • Silent failures. If a submit seems to do nothing, check the submission inbox in your dashboard and your email spam filter. The entry may have been caught by spam filtering rather than lost.

FAQ

Do I need an Astro SSR adapter or an API route for a contact form?

No. The form POSTs straight to your ShipMyForm endpoint from the browser, so your Astro project can stay fully static. You do not need output: "server", a platform adapter (Node, Vercel, Netlify, Cloudflare), an src/pages/api route, or an Astro Action. ShipMyForm stores, spam-filters, and forwards every submission for you.

Does it work with a fully static Astro build?

Yes. A default astro build outputs static HTML, and a static page is all you need because the form talks to ShipMyForm directly. Deploy the output to Netlify, Vercel, Cloudflare Pages, or GitHub Pages with no server runtime and the form still works.

How do I show a success message without reloading the page?

Add an inline <script> to the .astro file that intercepts submit, POSTs with fetch() and a FormData body, and sends the header Accept: application/json. ShipMyForm returns { ok: true } as JSON instead of redirecting, so you can toggle a success element and keep the visitor on the page.

Why did my form script stop working after adding view transitions?

With <ClientRouter> (view transitions), Astro swaps the page without a full reload, so inline scripts that bound a submit handler on first load do not run again. Re-bind on the astro:page-load event, or use event delegation on document, so the handler is attached after every client-side navigation.

Can I upload files from an Astro form?

Yes. Add an <input type="file" name="attachment"> and submit as multipart/form-data. In a plain form set enctype="multipart/form-data"; with fetch, a FormData body already sends the right content type and boundary, so uploads work with no extra code.

Next steps