Lista de espera para un nuevo SaaS8 min de lectura
Crea una lista de espera en React Router
Una lista de espera de React Router en el footer de cada página: un fetcher envía a una sola action de ruta, etiqueta cada registro con su página y funciona sin JavaScript.
Dale a la lista de espera su propia ruta con una action que valide el email y lo reenvíe a tu backend de formularios con una clave secreta. Renderiza el formulario en tu layout con useFetcher, para que un registro desde cualquier página se quede en esa página y se etiquete con su ruta; sin JavaScript, el mismo formulario envía a la ruta de la lista de espera y su página muestra el resultado.
La guía principal tiene una pestaña de React Router con el formulario y su action en la ruta de inicio. Eso encaja con un sitio de una sola página. La mayoría de los sitios SaaS antes del lanzamiento tienen varias páginas, como precios, un post del blog y la página de inicio, y quieres el registro en todas y saber cuál funcionó. Esta página pone el formulario en el layout y envía todas las copias a una sola action.
Qué cambia en React Router
En modo framework, una action pertenece a una ruta, y un <Form> envía a la ruta en la que está. Un formulario en el layout está en todas las rutas, así que el componente que elijas decide qué pasa al enviar:
| Item | <Form method="post"> | <fetcher.Form action="/waitlist"> |
|---|---|---|
| Adónde envía | La ruta actual, salvo que configures action | La action de la ruta /waitlist |
| Después de enviar | Una navegación. La URL y el historial cambian | Sin navegación. El visitante se queda en la página |
| El resultado | El actionData de la ruta que se ejecutó | fetcher.data, compartido por todos los fetchers con la misma key |
| Sin JavaScript | Un envío normal | Un envío normal a /waitlist, que renderiza su propia página |
Un fetcher encaja con un registro en el footer: nadie quiere salir de la página de precios para unirse a una lista. Además se degrada bien. Antes de la hidratación, fetcher.Form es un <form> corriente que envía a /waitlist, así que esa ruta necesita una página propia para mostrar el resultado.
- El layout renderiza el formulario de lista de espera en todas las páginas, con un ref oculto formado a partir de la ruta de la página.
- Con JavaScript, fetcher.Form envía a /waitlist sin salir de la página. La key del fetcher es compartida, así que una nota en el header ve el mismo resultado.
- Sin JavaScript, el mismo formulario envía a /waitlist como una navegación normal.
- En ambos casos, la action de /waitlist comprueba el email y reenvía el registro a Ikiform con la clave secreta.
- Un fetcher lee el resultado de fetcher.data. Un envío normal llega a la página /waitlist, que lo muestra desde actionData.
Prepara el proyecto
La guía de servidor cubre cómo crear el formulario y una clave secreta. Los archivos de esta página:
app/
root.tsx
routes.ts
routes/home.tsx
routes/pricing.tsx
routes/waitlist.tsx
components/waitlist-form.tsx
react-router.config.ts
.envPon la clave en .env y léela desde process.env en la action. Los módulos de ruta se dividen para el navegador, pero action solo se ejecuta en el servidor, así que la clave nunca llega al cliente. Nunca la importes en un componente.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY en variables de entorno solo de servidor.La action envía email y ref. Si el formulario tiene una definición de campos en su pestaña Fields, debe incluir ambos:
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}Constrúyelo
Registra la ruta
import { index, type RouteConfig, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("pricing", "routes/pricing.tsx"),
route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;La ruta de la lista de espera
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) {
return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
}
const ref = String(form.get("ref") ?? "");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: SOURCE.test(ref) ? ref : "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
return data(
{
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
},
{ status: response?.status === 429 ? 429 : 400 },
);
}
export default function Waitlist({ actionData }: Route.ComponentProps) {
return (
<main>
<h1>Join the Acme waitlist</h1>
<WaitlistForm initial={actionData} />
</main>
);
}data()establece el estado de un error sin convertirlo en una respuesta lanzada, así que el componente sigue renderizando el mensaje.- Cada respuesta devuelve el email, para que el input pueda volver a mostrarlo tras un error.
- La cabecera
Origindel navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos desdelocalhost, y elfetchpropio de la action no envía ninguna. refdebe contener letras minúsculas, dígitos,-o_, hasta 40 caracteres, el mismomax_lengthque la definición. Cualquier otra cosa se guarda comodirect.- El export por defecto es el plan B sin JavaScript. Renderiza el mismo formulario con el resultado de la action.
El formulario compartido
import { useFetcher, useLocation } from "react-router";
interface Outcome {
ok: boolean;
message: string;
email: string;
}
export function sourceFor(pathname: string) {
const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
return (slug || "home").slice(0, 40);
}
export function WaitlistForm({ initial }: { initial?: Outcome }) {
const fetcher = useFetcher<Outcome>({ key: "waitlist" });
const { pathname } = useLocation();
const outcome = fetcher.data ?? initial;
const pending = fetcher.state !== "idle";
if (outcome?.ok) return <p role="status">{outcome.message}</p>;
return (
<fetcher.Form method="post" action="/waitlist">
<label>
Email
<input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
</label>
<input type="hidden" name="ref" value={sourceFor(pathname)} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<p role="alert">{outcome?.message}</p>
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</fetcher.Form>
);
}
export function JoinedNote() {
const fetcher = useFetcher<Outcome>({ key: "waitlist" });
return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}key: "waitlist"hace que todos los fetchers con esa key sean un único fetcher compartido.JoinedNoteen el header lee el mismofetcher.data, así que cambia en cuanto el formulario del footer tiene éxito.sourceForconvierte/blog/launchenblog-launchy/enhome. La ruta se conoce en el servidor, así que también funciona en páginas prerenderizadas.fetcher.statevalesubmittingmientras se hace el envío y luegoloadingmientras React Router revalida, así que el botón sigue desactivado hasta que terminan ambos.- La etiqueta envuelve el input, así que las copias del hero y del footer en una misma página no comparten un
id.
Renderiza <WaitlistForm /> en el Layout de app/root.tsx, dentro de un footer con id="waitlist", y <JoinedNote /> en tu header. El honeypot viaja con el resto y llega a Ikiform sin tocar. Los envíos con la clave secreta se saltan la verificación humana de Ikiform, así que activar Spam check no aporta nada a este formulario.
Evita recargar todos los loaders
Después de una action, React Router recarga los datos de todas las rutas de la página. Un registro no cambia ninguno. Añade esto a las rutas cuyos loaders sean lentos o cuesten dinero:
import type { ShouldRevalidateFunctionArgs } from "react-router";
export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
if (formAction === "/waitlist") return false;
return defaultShouldRevalidate;
}Renderizado y despliegue
Las páginas de marketing no cambian según el visitante, así que prerenderízalas y reserva el servidor para la action:
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
prerender: ["/", "/pricing"],
} satisfies Config;Deja ssr: true. Con ssr: false no hay servidor en tiempo de ejecución, y React Router hace fallar la build de cualquier ruta que exporte una action. Con ssr: true, / y /pricing se escriben como HTML en el build, y /waitlist sigue ejecutándose en el servidor.
| Hosting | Qué añadir | Leer la clave |
|---|---|---|
| Vercel | @vercel/react-router, con presets: [vercelPreset()] en react-router.config.ts | process.env, configurada en Environment Variables |
| Netlify | @netlify/vite-plugin-react-router, como netlifyReactRouter() en los plugins de Vite | process.env, configurada en Environment variables |
| Cloudflare Workers | @cloudflare/vite-plugin, como cloudflare() antes de reactRouter() | env.IKIFORM_SECRET_KEY de cloudflare:workers, configurada con wrangler secret put |
En Cloudflare, haz import { env } from "cloudflare:workers" y lee la clave desde ahí en lugar de process.env. En React Router 8, el context de la action es un RouterContextProvider, así que el patrón context.cloudflare.env de la v7 ya no sirve. El resto de la action no cambia.
Comprueba que funciona
- Ejecuta
react-router dev, abrehttp://localhost:5173/pricingy regístrate en el footer. La línea de agradecimiento sustituye al formulario, y la nota del header cambia con ella. - Abre Submissions en el panel. El registro tiene
refenpricing. - Desactiva JavaScript y regístrate desde la página de inicio. Llegas a
/waitlistcon la línea de agradecimiento, y el registro tienerefenhome. - Envía
a@bdesde el footer. El error aparece bajo el input con la dirección todavía escrita. - Envía con el honeypot relleno,
_gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no manda email ni webhook. - Los envíos desde
react-router devllevan un origenlocalhost, así que cuentan como envíos de prueba. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desdelocalhost. Para pasar a producción, elige Pro desde 6 $/mes por 500 envíos, o paga una sola vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.
Problemas habituales
| Problema | Qué hacer |
|---|---|
Un registro en el footer navega a /waitlist incluso con JavaScript activo | El formulario es un <Form> o un <form> normal, no fetcher.Form. Usa el fetcher y no devuelvas un redirect() desde la action; una redirección desde la action de un fetcher provoca una navegación. |
La build falla con un error sobre exports de action | ssr está en false en react-router.config.ts. Ponlo en true, o la lista de espera no tendrá servidor en el que ejecutarse. |
El ref de todos los registros es waitlist | Los envíos vienen de la propia página /waitlist. Esa es la página de plan B; los enlaces deberían apuntar a tus páginas reales. |
422 con unknown_field | La definición de campos no incluye ref. Añádelo como en la definición de arriba. |
402 plan_required | El espacio de trabajo no tiene plan y el envío vino de un sitio en producción. Queda retenido, no se pierde. Elige un plan o compra un pack en Settings › Plan and usage y los envíos retenidos llegarán. |
Preguntas
Usa useFetcher y renderiza fetcher.Form con la action de la ruta que lo gestiona. El visitante se queda en la página y el resultado llega en fetcher.data. Antes de la hidratación, envía como un formulario normal.
Sí, cuando ssr es true. Las páginas prerenderizadas se sirven como HTML, y sus formularios envían a una action que se ejecuta en el servidor. Con ssr en false, no se permiten actions.
Pon la ruta de la página en un input oculto ref, como hace sourceFor arriba. La ruta se conoce en el servidor y en el cliente, así que funciona en páginas prerenderizadas y antes de la hidratación.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.