---
title: "Manejo de formularios con seguridad de tipos en Astro con Actions: validación, protección anti-spam y subida de archivos"
description: "Cree formularios de contacto, lista de espera y subida de archivos listos para producción en Astro con Actions: validación con Zod en el servidor, protección anti-bots con Turnstile, Cloudflare D1 y R2, y Resend, todo sin montar un backend aparte."
source: "https://www.launchfa.st/es/blog/astro-actions-forms"
author: "Rishi Raj Jain"
created_at: 2026-07-27T00:00:00.000Z
updated_at: 2026-07-27T00:00:00.000Z
---

> **Site index for agents**
> Fetch https://www.launchfa.st/llms.txt to discover every page on this site.
> Every page is also available as Markdown by appending `.md` to its URL.

<img width="1600" height="900" alt="Manejo de formularios con seguridad de tipos en Astro con Actions" decoding="async" loading="eager" class="mt-4 border rounded-sm bg-cover bg-center bg-no-repeat transform will-change-auto" src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/astro-actions-forms.png" />

Casi todos los sitios necesitan un formulario (un cuadro de contacto, un registro en lista de espera, una subida de archivos), y enseguida ese formulario empieza a convertirse en un backend. Empieza con un `<form>` en HTML, luego necesita validación en el servidor, luego protección anti-spam, luego un lugar donde guardar los datos, luego un correo transaccional.

[Astro Actions](https://docs.astro.build/en/guides/actions/) le permiten crear una función de servidor con seguridad de tipos que llama directamente desde el navegador como si fuera una función local. Astro se encarga de la serialización, ejecuta la validación con Zod en el servidor y le devuelve resultados y errores tipados. En esta guía construirá tres formularios con Actions: un formulario de contacto, una lista de espera y una subida de archivos, todos ejecutándose en el runtime de Cloudflare Workers con Turnstile, Cloudflare D1, Cloudflare R2 y Resend.

## Requisitos previos

Necesitará lo siguiente:

- [Node.js 22](https://nodejs.org/en/blog/announcements/v22-release-announce) o posterior
- Una [cuenta de Cloudflare](https://dash.cloudflare.com/sign-up) (D1, R2, Turnstile, todos con plan gratuito)
- Una cuenta de [Resend](https://resend.com) para enviar correos

## Por qué Astro Actions para formularios

Antes de Actions, manejar un formulario en Astro significaba crear un [endpoint de API](https://docs.astro.build/en/guides/endpoints/), parsear `FormData` a mano, validarlo manualmente y duplicar tipos entre el cliente y el servidor. Actions reemplaza todo eso con una única definición que es:

- **Con seguridad de tipos de extremo a extremo**: el esquema de entrada y el valor de retorno se infieren en ambos lados. Si cambia el esquema, la llamada del cliente falla en la comprobación de tipos.
- **Validada en el servidor**: la entrada se parsea con Zod (importado desde `astro/zod`) antes de que se ejecute su handler, de modo que una entrada inválida nunca llega a su lógica.
- **Invocable directamente**: desde un `<script>` llama a `actions.contact(formData)` y recibe `{ data, error }`. Los errores de validación por campo llegan estructurados, así que puede mostrarlos en línea.
- **Compatible con la mejora progresiva**: Actions acepta `FormData` nativo, de modo que el mismo handler funciona tanto si envía con JavaScript como si recurre a un envío de formulario HTML normal.

El atractivo está en la ergonomía de llamar a una función con la seguridad de un backend real.

## Configurar Cloudflare Turnstile

Turnstile es la alternativa gratuita y respetuosa con la privacidad a los CAPTCHA de Cloudflare, que mantiene bots y spam fuera de sus formularios sin obligar a los usuarios reales a resolver acertijos.

Configúrelo antes de escribir código, porque necesitará sus claves enseguida:

1. En el [panel de Cloudflare](https://dash.cloudflare.com), abra **Application Security > Turnstile** y seleccione **Add widget manually**.
2. Póngale un nombre y añada sus dominios, incluido `localhost` para el desarrollo local.
3. Deje el modo en **Managed**, que permite a Cloudflare decidir cuándo desafiar a un visitante.
4. Copie las dos claves que genera: la **Site Key** (pública, usada en el navegador) y la **Secret Key** (privada, para la verificación en el servidor).

Tenga ambas a mano para los archivos de entorno más adelante.

## Crear una nueva aplicación de Astro

Empecemos creando un nuevo proyecto de Astro. Ejecute el siguiente comando:

```bash
npm create astro@latest my-astro-forms-app
```

`npm create astro` es la forma recomendada de crear un proyecto de Astro rápidamente.

Cuando se le pregunte, elija:

- `Empty` cuando se le pregunte cómo iniciar el nuevo proyecto.
- `Yes` cuando se le pregunte si va a escribir TypeScript.
- `Strict` cuando se le pregunte qué tan estricto debe ser TypeScript.
- `Yes` cuando se le pregunte si instalar las dependencias.
- `Yes` cuando se le pregunte si inicializar un repositorio de Git.

Luego entre en el directorio del proyecto:

```bash
cd my-astro-forms-app
```

## Añadir el adaptador de Cloudflare para SSR

Actions se renderiza bajo demanda, así que el proyecto necesita el adaptador de Cloudflare. Este también proporciona D1, R2 y Turnstile en el mismo runtime de Workers.

```bash
npx astro add cloudflare
```

Eso configura `astro.config.mjs`. Este ejemplo lo mantiene al mínimo:

```js
// astro.config.mjs
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'

export default defineConfig({
  adapter: cloudflare({
    imageService: 'passthrough',
  }),
})
```

## Enlazar Cloudflare D1 y R2

Cree el almacenamiento que necesitan los formularios: una base de datos D1 para la lista de espera y un bucket R2 para las subidas:

```bash
npx wrangler d1 create forms
npx wrangler r2 bucket create forms-uploads
```

`wrangler d1 create` y `wrangler r2 bucket create` añaden los bindings (y el `database_id`) a `wrangler.jsonc` por usted, creando el archivo si no existe. El resultado se ve así:

```jsonc
// wrangler.jsonc
{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "my-astro-forms-app",
  "compatibility_date": "2026-07-27",
  "compatibility_flags": ["global_fetch_strictly_public"],
  "main": "@astrojs/cloudflare/entrypoints/server",
  "assets": { "directory": "./dist", "binding": "ASSETS" },
  "observability": { "enabled": true },
  "d1_databases": [
    {
      "binding": "forms",
      "database_name": "forms",
      "database_id": "<your-d1-database-id>",
      "remote": true
    }
  ],
  "r2_buckets": [
    {
      "bucket_name": "forms-uploads",
      "binding": "forms_uploads",
      "remote": true
    }
  ]
}
```

La bandera `remote: true` le indica a Wrangler que use sus recursos reales de D1 y R2 incluso durante `astro dev`, así que ejecute `npx wrangler login` una vez antes de empezar.

Los secretos se mantienen fuera de `wrangler.jsonc`. Póngalos en `.dev.vars` para el desarrollo local (Wrangler carga el archivo automáticamente durante `astro dev`):

```bash
# .dev.vars
RESEND_API_KEY="re_..."
CONTACT_FROM_EMAIL="Acme <onboarding@resend.dev>"
# Turnstile "always passes" test secret (swap for your real secret in production).
TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"
```

Y ponga la Site Key pública de Turnstile en `.env` para que pueda incrustarse en el cliente:

```bash
# .env
PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"
```

Por último, cree la tabla `waitlist`:

```sql
-- schema.sql
CREATE TABLE IF NOT EXISTS waitlist (
  email TEXT PRIMARY KEY,
  created_at TEXT NOT NULL
);
```

```bash
npx wrangler d1 execute forms --remote --file=./schema.sql
```

## Tipar el entorno de ejecución

Actions lee los bindings y los secretos del módulo `cloudflare:workers`. Genere los tipos para ellos directamente desde su `wrangler.jsonc` para que `env` esté completamente tipado:

```bash
npx wrangler types
```

Eso escribe un `worker-configuration.d.ts` que describe sus bindings `forms` y `forms_uploads` junto con los secretos. Ahora instale Resend y estará listo para escribir actions:

```bash
npm install resend
```

## Construir un formulario de contacto con seguridad de tipos

Las actions se definen en `src/actions/index.ts` y se exportan desde un único objeto `server`. Empecemos con el formulario de contacto.

### Definir la action de contacto

Cada action declara cómo acepta la entrada (`'form'` para `FormData`), un esquema Zod en `input` y un `handler`. El esquema es la validación: lo que no coincide nunca llega a su handler, y el error se devuelve al cliente como errores estructurados por campo. Los bindings y los secretos provienen del `env` de `cloudflare:workers`, y el cliente de Resend se crea una sola vez en el ámbito del módulo:

```ts
// src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions'
import { z } from 'astro/zod'
import { Resend } from 'resend'
import { env } from 'cloudflare:workers'

const resend = new Resend(env.RESEND_API_KEY)

export const server = {
  contact: defineAction({
    accept: 'form',
    input: z.object({
      name: z.string().trim().min(1, 'Please enter your name.'),
      email: z.string().trim().email('Enter a valid email address.'),
      message: z.string().trim().min(10, 'Your message should be at least 10 characters.'),
      // Injected by the Turnstile widget as a hidden field.
      'cf-turnstile-response': z.string().min(1, 'Please complete the anti-bot check.'),
    }),
    handler: async (input, context) => {
      // ...verify Turnstile, then send the email (below)
      return { ok: true }
    },
  }),
}
```

### Bloquear bots con Turnstile

Un formulario de contacto público es un imán para el spam. [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) le ofrece una alternativa a los CAPTCHA respetuosa con la privacidad. El widget añade un token `cf-turnstile-response` al formulario, que usted verifica en el servidor. Añada una pequeña función auxiliar y llámela primero en el handler:

```ts
/**
 * Verify a Cloudflare Turnstile token server-side.
 * Docs: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
 */
async function verifyTurnstile(token: string, secret: string, ip?: string) {
  const body = new FormData()
  body.append('secret', secret)
  body.append('response', token)
  if (ip) body.append('remoteip', ip)

  const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    body,
  })
  const data = (await res.json()) as { success: boolean }
  return data.success
}
```

`ActionError` le permite fallar con un código de estilo HTTP adecuado que el cliente puede distinguir de un error de validación:

```ts
// inside the contact handler:
const ip = context.request.headers.get('CF-Connecting-IP') ?? undefined

const passed = await verifyTurnstile(input['cf-turnstile-response'], env.TURNSTILE_SECRET_KEY, ip)
if (!passed) {
  throw new ActionError({
    code: 'FORBIDDEN',
    message: 'Anti-bot verification failed. Please try again.',
  })
}
// ...send email
```

### Enviar el correo con Resend

Con la solicitud validada y verificada, entréguela. El SDK de Resend funciona sin problemas en el runtime de Workers:

```ts
const { error } = await resend.emails.send({
  from: env.CONTACT_FROM_EMAIL,
  to: input.email,
  subject: `New contact message from ${input.name}`,
  text: `From: ${input.name} <${input.email}>\n\n${input.message}`,
})
if (error) {
  throw new ActionError({
    code: 'INTERNAL_SERVER_ERROR',
    message: 'Could not send your message. Please try again later.',
  })
}

return { ok: true }
```

Aquí la confirmación va a la dirección indicada en el formulario (`input.email`). Para enviar los mensajes de contacto a su propia bandeja de entrada en su lugar, envíe `to` a su dirección y establezca `replyTo: input.email`.

## Añadir una lista de espera respaldada por Cloudflare D1

La lista de espera guarda correos en D1 a través del binding `env.forms`. El detalle interesante es la idempotencia: como `email` es la clave primaria, un registro repetido genera un error de restricción `UNIQUE`, que tratamos como un éxito.

```ts
joinWaitlist: defineAction({
  accept: 'form',
  input: z.object({
    email: z.string().trim().toLowerCase().email('Enter a valid email address.'),
  }),
  handler: async (input, context) => {
    try {
      await env.forms
        .prepare('INSERT INTO waitlist (email, created_at) VALUES (?, ?)')
        .bind(input.email, new Date().toISOString())
        .run()
    } catch (err) {
      // A UNIQUE violation just means they already signed up, so treat it as success.
      if (!String(err).includes('UNIQUE')) {
        throw new ActionError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Could not join the waitlist. Please try again.',
        })
      }
    }

    await resend.emails.send({
      from: env.CONTACT_FROM_EMAIL,
      to: input.email,
      subject: "You're on the waitlist 🎉",
      text: "Thanks for joining! We'll email you the moment we launch.",
    })

    return { ok: true, email: input.email }
  },
}),
```

Fíjese en `z.string().trim().toLowerCase()`. Zod normaliza la entrada como parte de la validación, de modo que `Ada@Example.com ` y `ada@example.com` no crean dos filas.

## Manejar subidas de archivos a Cloudflare R2

Con subidas hechas a mano, tiene que parsear cuerpos multipart, validar el archivo y transmitirlo al almacenamiento. Con Actions, el archivo llega como un `File` estándar, y lo valida con el mismo estilo de esquema Zod que usa para todo lo demás. El bucket R2 es el binding `env.forms_uploads`.

```ts
upload: defineAction({
  accept: 'form',
  input: z.object({
    file: z
      .instanceof(File, { message: 'Please choose a file to upload.' })
      .refine((f) => f.size > 0, 'The selected file is empty.')
      .refine((f) => f.size <= 5 * 1024 * 1024, 'File must be 5 MB or smaller.')
      .refine(
        (f) => ['image/png', 'image/jpeg', 'application/pdf'].includes(f.type),
        'Only PNG, JPEG, or PDF files are allowed.',
      ),
  }),
  handler: async (input, context) => {
    const ext = input.file.name.split('.').pop()?.toLowerCase() ?? 'bin'
    const key = `uploads/${crypto.randomUUID()}.${ext}`

    await env.forms_uploads.put(key, await input.file.arrayBuffer(), {
      httpMetadata: { contentType: input.file.type },
    })

    return { ok: true, key, size: input.file.size }
  },
}),
```

Validar el tipo y el tamaño *antes* de tocar R2 significa que rechaza una subida de 4 GB o un ejecutable disfrazado en el borde de su handler.

## Conectar los formularios en el cliente

Ahora el frontend. Cargue el script de Turnstile, renderice los tres formularios y llame a las actions desde un `<script>`. La importación del cliente `astro:actions` le da un objeto `actions` tipado y una función auxiliar `isInputError` para extraer los mensajes de validación por campo.

```astro
---
// src/pages/index.astro
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY
---

<form id="contact">
  <h2>Contact</h2>
  <label>Name <input name="name" required /></label>
  <label>Email <input name="email" type="email" required /></label>
  <label>Message <textarea name="message" rows="4" required></textarea></label>
  <div class="cf-turnstile" data-sitekey={siteKey}></div>
  <button type="submit">Send message</button>
  <p class="status" role="status"></p>
</form>

<form id="waitlist">
  <h2>Join the waitlist</h2>
  <label>Email <input name="email" type="email" required /></label>
  <button type="submit">Join</button>
  <p class="status" role="status"></p>
</form>

<form id="upload">
  <h2>Upload a file</h2>
  <label>File (PNG, JPEG, or PDF, max 5 MB) <input name="file" type="file" required /></label>
  <button type="submit">Upload</button>
  <p class="status" role="status"></p>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" />

<script>
  import { actions, isInputError } from 'astro:actions'

  const forms: Record<string, (data: FormData) => Promise<{ data?: { ok: boolean }; error?: any }>> = {
    contact: actions.contact,
    waitlist: actions.joinWaitlist,
    upload: actions.upload,
  }

  for (const [id, action] of Object.entries(forms)) {
    const form = document.getElementById(id) as HTMLFormElement | null
    if (!form) continue
    const status = form.querySelector('.status') as HTMLParagraphElement

    form.addEventListener('submit', async (event) => {
      event.preventDefault()
      status.textContent = 'Submitting…'

      const { data, error } = await action(new FormData(form))

      if (error) {
        // Field-level validation errors come back typed from the Zod schema.
        const message = isInputError(error)
          ? Object.values(error.fields).flat()[0] ?? 'Please check the form and try again.'
          : error.message
        status.textContent = message
        return
      }

      status.textContent = data?.ok ? 'Done ✅' : 'Submitted.'
      form.reset()
      // @ts-expect-error global injected by the Turnstile script
      window.turnstile?.reset?.()
    })
  }
</script>
```

La línea clave es `await action(new FormData(form))`. No hay ninguna llamada `fetch` ni ningún endpoint que escribir. Llama a la action con los datos del formulario y recibe un `{ data, error }` tipado. `isInputError` distingue los errores de validación de Zod (mostrar en línea) de los `ActionError` lanzados (mostrar el mensaje).

## Ejecutarlo localmente

Inicie el servidor de desarrollo:

```bash
npm run dev
```

Como los bindings están marcados con `remote: true`, el desarrollo habla con sus recursos reales de D1 y R2 (asegúrese de haber ejecutado `npx wrangler login`). Abra [localhost:4321](http://localhost:4321), envíe el formulario de contacto (la test key de Turnstile se acepta automáticamente), únase a la lista de espera y suba un archivo.

## Desplegar en Cloudflare

`wrangler.jsonc` ya apunta `main` al punto de entrada de servidor del adaptador y `assets` a `./dist`, así que desplegar consiste en un build seguido de `wrangler deploy`. Aplique primero el esquema y suba sus secretos:

```bash
npx wrangler d1 execute forms --remote --file=./schema.sql
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put TURNSTILE_SECRET_KEY
npx wrangler secret put CONTACT_FROM_EMAIL

npm run build && npx wrangler deploy
```

Cambie las test keys de Turnstile por sus [Site Key y Secret Key](https://developers.cloudflare.com/turnstile/get-started/) reales, apunte `CONTACT_FROM_EMAIL` a un [dominio verificado de Resend](https://resend.com/docs/dashboard/domains/introduction) y sus formularios estarán funcionando en el edge.

## Referencias

- [Repositorio de GitHub](https://github.com/rishi-raj-jain/my-astro-forms-app)
- [Documentación de Astro Actions](https://docs.astro.build/en/guides/actions/)
- [Validación de Turnstile en el servidor](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)
- [SDK de Resend para Node.js](https://resend.com/docs/send-with-nodejs)

## Conclusión

En esta guía construyó tres formularios de aspecto listo para producción en Astro (contacto, lista de espera y subida de archivos) usando Actions para una lógica con seguridad de tipos y validada en el servidor, con Turnstile para la protección anti-spam, Cloudflare D1 y R2 para el almacenamiento y Resend para el correo. El patrón escala: cualquier formulario que añada más adelante es solo otra entrada en el objeto `server`, validada por Zod e invocable directamente desde el cliente, sin ninguna API aparte que construir o mantener.

Si prefiere partir de una base SaaS con todo incluido, con autenticación, facturación, almacenamiento y correo ya conectados en Astro y Cloudflare, eche un vistazo a [LaunchFast](https://www.launchfa.st).

Si tiene preguntas o comentarios, no dude en escribirme por [Twitter](https://twitter.com/direct_messages/create/rishi_raj_jain_).
