Manejo de formularios con seguridad de tipos… | LaunchFast
LaunchFast LogoLaunchFast
Blog
3164 palabras16 min de lectura

Manejo de formularios con seguridad de tipos en Astro con Actions: validación, protección anti-spam y subida de archivos

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.

Rishi Raj Jain
Rishi Raj JainAutor
Manejo de formularios con seguridad de tipos en Astro con Actions

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

Por qué Astro Actions para formularios

Antes de Actions, manejar un formulario en Astro significaba crear un endpoint de API, 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, 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:

Terminal window
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:

Terminal window
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.

Terminal window
npx astro add cloudflare

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

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:

Terminal window
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í:

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

.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:

.env
PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"

Por último, cree la tabla waitlist:

-- schema.sql
CREATE TABLE IF NOT EXISTS waitlist (
email TEXT PRIMARY KEY,
created_at TEXT NOT NULL
);
Terminal window
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:

Terminal window
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:

Terminal window
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:

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

/**
* 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:

// 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:

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.

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.

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.

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:

Terminal window
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, 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:

Terminal window
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 reales, apunte CONTACT_FROM_EMAIL a un dominio verificado de Resend y sus formularios estarán funcionando en el edge.

Referencias

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.

Si tiene preguntas o comentarios, no dude en escribirme por Twitter.

Sigue leyendo