Lista de espera para un nuevo SaaS

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.

Preet SutharFundador, Ikiform

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íaLa ruta actual, salvo que configures actionLa action de la ruta /waitlist
Después de enviarUna navegación. La URL y el historial cambianSin navegación. El visitante se queda en la página
El resultadoEl actionData de la ruta que se ejecutófetcher.data, compartido por todos los fetchers con la misma key
Sin JavaScriptUn envío normalUn 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.

Los registros del footer desde cualquier página de React Router pasan por una sola action de ruta/pricingref = pricingfetcher.Formkey: waitlistaction de/waitlistreenvía aIkiformfetcher.datamisma página, elheader cambia/blog/launchref =blog-launchformularionormalJavaScript sincargaraction de/waitlistel mismo códigopágina/waitlistactionDataPOSTPOSTpáginaformularioactionresultadoLos registros del footer desde cualquier página de React Router pasan por una sola action de ruta/pricingref = pricingfetcher.Formkey: waitlistaction de /waitlistreenvía a Ikiformfetcher.datamisma página, el header cambia/blog/launchref = blog-launchformulario normalJavaScript sin cargaraction de /waitlistel mismo códigopágina /waitlistactionDataPOSTPOST
  1. 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.
  2. 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.
  3. Sin JavaScript, el mismo formulario envía a /waitlist como una navegación normal.
  4. En ambos casos, la action de /waitlist comprueba el email y reenvía el registro a Ikiform con la clave secreta.
  5. Un fetcher lee el resultado de fetcher.data. Un envío normal llega a la página /waitlist, que lo muestra desde actionData.
Una action, cualquier número de formularios. La ruta te dice qué página consiguió el registro.

Prepara el proyecto

La guía de servidor cubre cómo crear el formulario y una clave secreta. Los archivos de esta página:

Proyecto
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env

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

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
Las versiones de servidor envían con una clave secreta, así que Ikiform se salta Allowed domains y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_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:

Campos
{
  "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

app/routes.ts
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

app/routes/waitlist.tsx
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 Origin del navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost, y el fetch propio de la action no envía ninguna.
  • ref debe contener letras minúsculas, dígitos, - o _, hasta 40 caracteres, el mismo max_length que la definición. Cualquier otra cosa se guarda como direct.
  • El export por defecto es el plan B sin JavaScript. Renderiza el mismo formulario con el resultado de la action.

El formulario compartido

app/components/waitlist-form.tsx
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. JoinedNote en el header lee el mismo fetcher.data, así que cambia en cuanto el formulario del footer tiene éxito.
  • sourceFor convierte /blog/launch en blog-launch y / en home. La ruta se conoce en el servidor, así que también funciona en páginas prerenderizadas.
  • fetcher.state vale submitting mientras se hace el envío y luego loading mientras 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:

app/routes/pricing.tsx
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:

react-router.config.ts
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.

HostingQué añadirLeer la clave
Vercel@vercel/react-router, con presets: [vercelPreset()] en react-router.config.tsprocess.env, configurada en Environment Variables
Netlify@netlify/vite-plugin-react-router, como netlifyReactRouter() en los plugins de Viteprocess.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

  1. Ejecuta react-router dev, abre http://localhost:5173/pricing y regístrate en el footer. La línea de agradecimiento sustituye al formulario, y la nota del header cambia con ella.
  2. Abre Submissions en el panel. El registro tiene ref en pricing.
  3. Desactiva JavaScript y regístrate desde la página de inicio. Llegas a /waitlist con la línea de agradecimiento, y el registro tiene ref en home.
  4. Envía a@b desde el footer. El error aparece bajo el input con la dirección todavía escrita.
  5. 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.
  6. Los envíos desde react-router dev llevan un origen localhost, 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 desde localhost. 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

ProblemaQué hacer
Un registro en el footer navega a /waitlist incluso con JavaScript activoEl 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 actionssr 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 waitlistLos 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_fieldLa definición de campos no incluye ref. Añádelo como en la definición de arriba.
402 plan_requiredEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.