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) |