---
title: "Add forms to Astro"
description: "Add a form backend to your Astro site. Static client-side forms, framework islands, or type-safe Astro Actions — all powered by Forminit. Built for Astro 6."
url: https://forminit.com/docs/astro/
---

# Add forms to Astro

![Forminit+Astro](https://forminit.com/guides/astro-thumbnail.png)

Astro ships zero JavaScript by default and renders pages three different ways — fully static, hydrated **islands**, and **on-demand** server routes. Forminit fits all three: keep a contact form static and lightweight, drop in a React island when you need interactivity, or handle submissions server-side with **Astro Actions** so your API key never touches the browser.

> **Build with AI:** give your AI coding tool your Form ID and this integration skill guide: https://forminit.com/skills/forminit-html/SKILL.md

**What you'll get:**
- Accept form submissions without building a backend
- Email notifications for new submissions
- Spam protection (Cloudflare Turnstile, reCAPTCHA, hCaptcha, honeypot)
- File uploads support
- Submission dashboard to manage responses

**Requirements:**
- **Astro 6** (Node.js 22.12+) recommended. Client-side approaches work back to Astro 3.0; Astro Actions need Astro 4.15+ and a server adapter.
- A Forminit account and Form ID

---

## Choose your integration

Pick the approach that matches how your page is rendered.

| Approach | Best for | Rendering | Forminit mode |
|---|---|---|---|
| **1. Client-side script** | Static marketing sites, simple contact/waitlist forms | Static (`prerender`) | **Public** |
| **2. Framework island** | You already use React/Vue/Svelte/Solid components | Static + `client:*` | **Public** |
| **3. Astro Actions** | Hidden API key, server-side validation, works without JS | On-demand (adapter) | **Protected** |

If you're not sure, start with **Approach 1** — it works on any static host with no build configuration.

---

## Step 1: Create a Form on Forminit

1. Sign up or log in at [forminit.com](https://forminit.com)
2. Create a new form
3. Open **Form Settings → Authentication mode**:
   - Set **Public** for Approaches 1 & 2 (client-side)
   - Set **Protected** for Approach 3 (server-side, requires an API key)
4. Copy your **Form ID** (e.g., `YOUR_FORM_ID`)

---

## Approach 1: Client-Side Script

The simplest path. Your `.astro` component stays static and loads the Forminit SDK from the CDN — no npm dependency, no build step, works on any static host. Uses **Public** mode.

```astro
---
// src/components/ContactForm.astro
---

<form id="contact-form" class="contact-form">
  <input type="text"  name="fi-sender-firstName" placeholder="First name" required />
  <input type="text"  name="fi-sender-lastName"  placeholder="Last name" required />
  <input type="email" name="fi-sender-email"     placeholder="Email" required />
  <textarea name="fi-text-message" placeholder="Message" rows="5" required></textarea>

  <button type="submit">Send Message</button>
  <p id="form-status" aria-live="polite"></p>
</form>

<script>
  const FORM_ID = 'YOUR_FORM_ID'; // ← Replace with your Form ID
  const form = document.getElementById('contact-form');
  const status = document.getElementById('form-status');
  const button = form.querySelector('button[type="submit"]');

  // Load the Forminit SDK from the CDN
  const sdk = document.createElement('script');
  sdk.src = 'https://forminit.com/sdk/v1/forminit.js';
  sdk.onload = () => {
    const forminit = new Forminit();

    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      status.textContent = 'Sending...';
      button.disabled = true;

      const { data, error } = await forminit.submit(FORM_ID, new FormData(form));

      button.disabled = false;

      if (error) {
        status.textContent = error.message;
        return;
      }

      status.textContent = 'Message sent successfully!';
      form.reset();
    });
  };
  document.body.appendChild(sdk);
</script>
```

Every input name follows the `fi-{blockType}-{name}` pattern. This example uses a handful of blocks — see the [Form Blocks reference](https://forminit.com/docs/form-blocks/) for all of them (`number`, `select`, `radio`, `checkbox`, `rating`, `date`, `file`, `country`, and more).

Add it to any page:

```astro
---
// src/pages/contact.astro
import Layout from '../layouts/Layout.astro';
import ContactForm from '../components/ContactForm.astro';
---

<Layout title="Contact Us">
  <h1>Get in Touch</h1>
  <ContactForm />
</Layout>
```

---

## Approach 2: Framework Island

Already using a UI framework in Astro? Build the form as a hydrated island with the npm SDK. Also uses **Public** mode (the component runs in the browser).

First install the SDK:

```bash
npm install forminit
```

Create the island (React shown — the same idea applies to Vue, Svelte, and Solid):

```tsx
// src/components/ContactForm.tsx
import { useState, useRef, FormEvent } from 'react';
import { Forminit } from 'forminit';

const forminit = new Forminit();
const FORM_ID = 'YOUR_FORM_ID';

export default function ContactForm() {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const [error, setError] = useState<string | null>(null);
  const formRef = useRef<HTMLFormElement>(null);

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus('loading');

    const { error } = await forminit.submit(FORM_ID, new FormData(e.currentTarget));

    if (error) {
      setStatus('error');
      setError(error.message);
      return;
    }

    setStatus('success');
    formRef.current?.reset();
  }

  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <input type="text"  name="fi-sender-firstName" placeholder="First name" required />
      <input type="text"  name="fi-sender-lastName"  placeholder="Last name" required />
      <input type="email" name="fi-sender-email"     placeholder="Email" required />
      <textarea name="fi-text-message" placeholder="Message" required />

      {status === 'error' && <p className="error">{error}</p>}
      {status === 'success' && <p className="success">Message sent!</p>}

      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}
```

Hydrate it on the page with a [client directive](https://docs.astro.build/en/reference/directives-reference/#client-directives):

```astro
---
// src/pages/contact.astro
import ContactForm from '../components/ContactForm.tsx';
---

<ContactForm client:load />
```

> **Note**
>
> Use the npm SDK for islands (the component is bundled and hydrated). The CDN approach in Approach 1 is better for pure `.astro` components where you want to avoid a dependency.

---

## Approach 3: Astro Actions (Server-Side)

The modern, Astro-native pattern. [Actions](https://docs.astro.build/en/guides/actions/) handle the submission **on the server**, so your **Protected**-mode API key stays out of the browser, you get type-safe validation with Zod, and the form **works even with JavaScript disabled** (progressive enhancement).

**Requirements:** a [server adapter](https://docs.astro.build/en/guides/on-demand-rendering/) (Node, Netlify, Vercel, Cloudflare…) and an on-demand rendered page.

### 1. Install the SDK and an adapter

```bash
npm install forminit
npx astro add node
```

### 2. Store the API key as a server secret

Create an API token in **Account → API Tokens**, then declare it with Astro's type-safe [`astro:env`](https://docs.astro.build/en/guides/environment-variables/) so it's only ever available on the server:

```js
// astro.config.mjs
import { defineConfig, envField } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
  env: {
    schema: {
      FORMINIT_API_KEY: envField.string({ context: 'server', access: 'secret' }),
    },
  },
});
```

```bash
# .env
FORMINIT_API_KEY=fi_your_secret_api_key
```

### 3. Define the action

```ts
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
import { Forminit } from 'forminit';
import { FORMINIT_API_KEY } from 'astro:env/server';

const forminit = new Forminit({ apiKey: FORMINIT_API_KEY });
const FORM_ID = 'YOUR_FORM_ID';

export const server = {
  contact: defineAction({
    accept: 'form',
    input: z.object({
      firstName: z.string().min(1),
      lastName: z.string().min(1),
      email: z.email(),
      message: z.string().min(1),
    }),
    handler: async ({ firstName, lastName, email, message }) => {
      const { data, redirectUrl, error } = await forminit.submit(FORM_ID, {
        blocks: [
          { type: 'sender', properties: { firstName, lastName, email } },
          { type: 'text', name: 'message', value: message },
        ],
      });

      if (error) throw new Error(error.message);
      return { hashId: data.hashId, redirectUrl };
    },
  }),
};
```

Because the page POSTs straight to the action, the field names are plain (`email`, `message`) — Zod validates them and your handler maps them to Forminit blocks server-side.

### 4. Wire up the form

```astro
---
// src/pages/contact.astro
export const prerender = false; // on-demand render so the action can run

import Layout from '../layouts/Layout.astro';
import { actions } from 'astro:actions';

// On success, send the user to the redirect URL from your Forminit settings
const result = Astro.getActionResult(actions.contact);
if (result && !result.error) {
  return Astro.redirect(result.data.redirectUrl ?? '/thank-you');
}
---

<Layout title="Contact Us">
  <form method="POST" action={actions.contact}>
    <input type="text"  name="firstName" placeholder="First name" required />
    <input type="text"  name="lastName"  placeholder="Last name" required />
    <input type="email" name="email"     placeholder="Email" required />
    <textarea name="message" placeholder="Message" required></textarea>
    <button type="submit">Send Message</button>
  </form>

  {result?.error && <p class="error">Something went wrong. Please try again.</p>}
</Layout>
```

That's it — no client-side `<script>` required. The form submits with a normal POST and works without JavaScript; add a `client:*` island on top only if you want inline, no-reload feedback.

> **Note**
>
> **File uploads in actions:** add file fields to your Zod schema with `z.instanceof(File)` and forward the request's `FormData` (with `fi-file-{name}` fields) to `forminit.submit()` instead of JSON blocks. JSON submissions can't carry files.

---

## Form Field Reference

For every block type (`text`, `number`, `email`, `phone`, `url`, `select`, `radio`, `checkbox`, `rating`, `date`, `file`, `country`, plus the built-in `sender` and `tracking`), naming patterns, and validation rules, see the [Form Blocks documentation](https://forminit.com/docs/form-blocks/).

---

## Handling the Response

Every approach returns the same shape — `{ data, redirectUrl, error }`:

**On success:**

```javascript
{
  data: {
    hashId: "7LMIBoYY74JOCp1k",
    date: "2026-01-01 21:10:24",
    blocks: {
      sender: { firstName: "John", lastName: "Doe", email: "john@example.com" },
      message: "Hello from Astro!"
    }
  },
  redirectUrl: "https://forminit.com/thank-you"
}
```

**On error:**

```javascript
{
  error: {
    error: "ERROR_CODE",
    code: 400,
    message: "Human-readable error message"
  }
}
```

---

## Redirect After Submission

Every successful submission returns a `redirectUrl` you configure in **Form Settings → Redirections** — so you can change the thank-you destination anytime **without editing code or redeploying**.

**Client-side (Approaches 1 & 2):**

```javascript
const { redirectUrl, error } = await forminit.submit(FORM_ID, new FormData(form));
if (!error) window.location.href = redirectUrl;
```

**Astro Actions (Approach 3):** redirect from the page using the value the handler returned (shown in step 4):

```astro
const result = Astro.getActionResult(actions.contact);
if (result && !result.error) {
  return Astro.redirect(result.data.redirectUrl ?? '/thank-you');
}
```

---

## Spam Protection

Forminit forms support multiple spam-protection layers — add them to any of the approaches above:

- [Cloudflare Turnstile](https://forminit.com/docs/cloudflare-turnstile/)
- [reCAPTCHA v3](https://forminit.com/docs/recaptcha/)
- [hCaptcha](https://forminit.com/docs/hcaptcha/)
- [Honeypot](https://forminit.com/docs/honeypot/)

---

## Astro 6 Notes

- **Node.js 22.12+ is required.** Astro 6 dropped Node 18 and 20.
- **Keep your API key off the client.** In Astro 6, `import.meta.env` values are inlined at build time, so never read `FORMINIT_API_KEY` through `import.meta.env` in code that could reach the browser. Use `astro:env/server` (as in Approach 3) or `process.env` inside server-only action handlers — the key then only exists on the server.
- **`ClientRouter`'s `handleForms` prop was removed.** If you used view transitions to intercept form posts, Astro Actions are now the supported way to handle submissions.
- Client-side Approaches 1 & 2 are unaffected by these changes and run on any Astro version from 3.0 onward.

---

## Build and Deploy

```bash
npm run dev      # Development
npm run build    # Production build
npm run preview  # Preview the build
```

- **Approaches 1 & 2** produce a fully static site — deploy `dist/` to Netlify, Vercel, Cloudflare Pages, GitHub Pages, or any static host.
- **Approach 3** needs the server adapter you installed — deploy to a host that runs the on-demand output (Netlify, Vercel, Cloudflare, or a Node server).

---

## Next Steps

1. [View your submissions](https://app.forminit.com) in the Forminit dashboard
2. Set up [email notifications](https://forminit.com/docs/email-notifications/) for new submissions
3. Explore [webhook integrations](https://forminit.com/docs/webhooks/) for advanced workflows
4. Read the [Form Blocks reference](https://forminit.com/docs/form-blocks/) to capture exactly the data you need
