All docs
3 min read

Astro recipe

Astro can submit forms three ways: a static <form action>, an Astro form action (server runtime), or an API route that proxies. Use the right one for your deployment target.

Static form (no server)

Pure HTML, works on a static deployment (Cloudflare Pages, Netlify, GitHub Pages).

---
// src/pages/contact.astro
const endpoint = import.meta.env.PUBLIC_FORMSPRING_URL;
---
<form action={endpoint} method="POST">
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <input type="hidden" name="_redirect" value="/contact/thanks" />
  <button type="submit">Send</button>
</form>

_redirect makes Formspring 303 the user to your thank-you page after a successful submission.

Astro form action (server runtime)

Available on adapters that support server output (@astrojs/node, @astrojs/cloudflare, @astrojs/vercel). Form actions let you do server-side validation and post to Formspring without exposing the URL.

---
// src/pages/contact.astro
import { actions } from 'astro:actions';
const result = Astro.getActionResult(actions.contact);
---
<form method="POST" action={actions.contact}>
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button>Send</button>
</form>
{result?.data?.ok && <p>Thanks!</p>}
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  contact: defineAction({
    accept: 'form',
    input: z.object({
      email: z.string().email(),
      message: z.string().min(1),
    }),
    handler: async (input, ctx) => {
      const r = await fetch(ctx.locals.runtime.env.FORMSPRING_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      });
      if (!r.ok) {
        throw new Error('submit failed');
      }
      return { ok: true };
    },
  }),
};

API route as proxy

When you want full control over the response or to add server-side metadata:

// src/pages/api/contact.ts
import type { APIRoute } from 'astro';

export const POST: APIRoute = async ({ request, locals }) => {
  const data = await request.formData();
  data.append('_source', 'astro-site');

  const r = await fetch(locals.runtime.env.FORMSPRING_URL, {
    method: 'POST',
    body: data,
  });

  return new Response(JSON.stringify({ ok: r.ok }), {
    status: r.status,
    headers: { 'Content-Type': 'application/json' },
  });
};

Verify a webhook in an Astro endpoint

If your Astro site also receives webhooks (e.g. to invalidate caches when a submission lands):

// src/pages/api/formspring-webhook.ts
import type { APIRoute } from 'astro';
import { createHmac, timingSafeEqual } from 'node:crypto';

export const POST: APIRoute = async ({ request, locals }) => {
  const sig = request.headers.get('x-formspring-signature') ?? '';
  const raw = await request.text();
  const secret = locals.runtime.env.WEBHOOK_SECRET;

  const expected = createHmac('sha256', secret).update(raw).digest('hex');
  if (
    sig.length !== expected.length ||
    !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  ) {
    return new Response('unauthorized', { status: 401 });
  }

  const body = JSON.parse(raw);
  // …handle the event
  return new Response('ok');
};

On Cloudflare Pages with an adapter, node:crypto is polyfilled. On a pure-edge runtime, swap to crypto.subtle and a Uint8Array compare.

Deploy targets

Target Static form Action / API route
Cloudflare Pages yes yes (with @astrojs/cloudflare)
Vercel yes yes (with @astrojs/vercel)
Netlify yes yes (with @astrojs/netlify)
GitHub Pages yes no (static only)

What's next