Lista de espera para um novo SaaS

Lista de espera para um novo SaaS8 min de leitura

Monte uma lista de espera em React Router

Uma lista de espera em React Router no rodapé de todas as páginas: um fetcher envia para uma única route action, marca cada inscrição com a página e funciona sem JavaScript.

Preet SutharFundador, Ikiform

Dê à lista de espera uma rota própria com uma action que valida o e-mail e o encaminha ao seu backend de formulários com uma chave secreta. Renderize o formulário no seu layout com useFetcher, para que uma inscrição feita em qualquer página continue naquela página e seja marcada com o caminho dela; sem JavaScript, o mesmo formulário envia para a rota da lista de espera e a página dela mostra o resultado.

O guia principal tem uma aba de React Router com o formulário e a action dele na rota inicial. Isso serve para um site de uma página. A maioria dos sites SaaS antes do lançamento tem algumas páginas, como preços, um post de blog e a página inicial, e você quer a inscrição em todas elas e saber qual funcionou. Esta página coloca o formulário no layout e envia todas as cópias para uma única action.

O que muda no React Router

No framework mode, uma action pertence a uma rota, e um <Form> envia para a rota em que está. Um formulário no layout está em todas as rotas, então a escolha do componente decide o que acontece no envio:

Item<Form method="post"><fetcher.Form action="/waitlist">
Para onde enviaA rota atual, a menos que você defina actionA action da rota /waitlist
Depois do envioUma navegação. A URL e o histórico mudamNenhuma navegação. O visitante continua na página
O resultadoO actionData da rota que rodoufetcher.data, compartilhado por todos os fetchers com a mesma key
Sem JavaScriptUm envio normalUm envio normal para /waitlist, que renderiza a própria página

Um fetcher combina com uma inscrição no rodapé: ninguém quer sair da página de preços para entrar numa lista. Ele também degrada sem problemas. Antes da hidratação, fetcher.Form é um <form> comum que envia para /waitlist, então essa rota precisa de uma página própria para mostrar o resultado.

As inscrições do rodapé de todas as páginas do React Router passam por uma única route action/pricingref = pricingfetcher.Formkey: waitlistaction de/waitlistencaminha aoIkiformfetcher.datamesma página,header atualiza/blog/launchref =blog-launchformuláriosimplesJavaScript nãocarregadoaction de/waitlisto mesmo códigopágina/waitlistactionDataPOSTPOSTpáginaformulárioactionresultadoAs inscrições do rodapé de todas as páginas do React Router passam por uma única route action/pricingref = pricingfetcher.Formkey: waitlistaction de /waitlistencaminha ao Ikiformfetcher.datamesma página, header atualiza/blog/launchref = blog-launchformulário simplesJavaScript não carregadoaction de /waitlisto mesmo códigopágina /waitlistactionDataPOSTPOST
  1. O layout renderiza o formulário da lista de espera em todas as páginas, com um ref oculto feito a partir do caminho da página.
  2. Com JavaScript, o fetcher.Form envia para /waitlist sem sair da página. A key do fetcher é compartilhada, então um aviso no header vê o mesmo resultado.
  3. Sem JavaScript, o mesmo formulário envia para /waitlist como uma navegação normal.
  4. De qualquer jeito, a action de /waitlist confere o e-mail e encaminha a inscrição ao Ikiform com a chave secreta.
  5. Um fetcher lê o resultado em fetcher.data. Um envio simples cai na página /waitlist, que o mostra a partir do actionData.
Uma action, quantos formulários você quiser. O caminho diz qual página ganhou a inscrição.

Configure o projeto

O guia de servidor explica como criar o formulário e uma chave secreta. Os arquivos desta página:

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

Coloque a chave no .env e leia-a de process.env na action. Os route modules são divididos para o navegador, mas a action só roda no servidor, então a chave nunca vai para o cliente. Nunca a importe num componente.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.

A action envia email e ref. Se o formulário tiver uma definição de campos na aba Fields, ela precisa listar os dois:

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 } }
  ]
}

Construa

Registre a rota

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;

A rota da 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() define o status de um erro sem transformá-lo numa response lançada, então o componente ainda renderiza a mensagem.
  • Toda resposta traz o e-mail de volta, para o input poder mostrá-lo de novo depois de um erro.
  • O header Origin do navegador é repassado porque o Ikiform conta os envios de teste gratuitos de localhost por ele, e o fetch da própria action não envia nenhum.
  • ref precisa ser formado por letras minúsculas, dígitos, - ou _, até 40 caracteres, o mesmo max_length da definição. Qualquer outra coisa é armazenada como direct.
  • O default export é o plano B sem JavaScript. Ele renderiza o mesmo formulário com o resultado da action.

O formulário compartilhado

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" transforma todos os fetchers com essa key num único fetcher compartilhado. O JoinedNote no header lê o mesmo fetcher.data, então muda no instante em que o formulário do rodapé dá certo.
  • sourceFor transforma /blog/launch em blog-launch e / em home. O caminho é conhecido no servidor, então funciona também em páginas pré-renderizadas.
  • fetcher.state é submitting enquanto o envio roda, depois loading enquanto o React Router revalida, então o botão continua desativado até os dois terminarem.
  • O label envolve o input, então as cópias do hero e do rodapé numa mesma página não compartilham um id.

Renderize <WaitlistForm /> no Layout do app/root.tsx, dentro de um footer com id="waitlist", e <JoinedNote /> no seu header. O honeypot vai junto e chega intacto ao Ikiform. Envios com a chave secreta pulam a verificação humana do Ikiform, então ligar o Spam check não acrescenta nada para este formulário.

Evite recarregar todos os loaders

Depois de uma action, o React Router recarrega os dados de todas as rotas da página. Uma inscrição não muda nenhum deles. Adicione isto às rotas cujos loaders são lentos ou custam dinheiro:

app/routes/pricing.tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}

Renderização e deploy

Páginas de marketing não mudam por visitante, então pré-renderize-as e deixe o servidor para a action:

react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;

Mantenha ssr: true. Com ssr: false não existe servidor em runtime, e o React Router falha o build para qualquer rota que exporte uma action. Com ssr: true, / e /pricing são gravadas como HTML no build, e /waitlist continua rodando no servidor.

HostO que adicionarLendo a chave
Vercel@vercel/react-router, com presets: [vercelPreset()] no react-router.config.tsprocess.env, definida em Environment Variables
Netlify@netlify/vite-plugin-react-router, como netlifyReactRouter() nos plugins do Viteprocess.env, definida em Environment variables
Cloudflare Workers@cloudflare/vite-plugin, como cloudflare() antes de reactRouter()env.IKIFORM_SECRET_KEY de cloudflare:workers, definida com wrangler secret put

Na Cloudflare, use import { env } from "cloudflare:workers" e leia a chave dali em vez de process.env. No React Router 8, o context da action é um RouterContextProvider, então o padrão context.cloudflare.env da v7 não vale mais. O resto da action continua igual.

Confira se funciona

  1. Rode react-router dev, abra http://localhost:5173/pricing e inscreva-se no rodapé. A frase de agradecimento substitui o formulário, e o aviso do header muda junto.
  2. Abra Submissions no painel. A inscrição tem ref definido como pricing.
  3. Desligue o JavaScript e inscreva-se pela página inicial. Você cai em /waitlist com a frase de agradecimento, e a inscrição tem ref definido como home.
  4. Envie a@b pelo rodapé. O erro aparece abaixo do input com o endereço ainda digitado.
  5. Envie com o honeypot preenchido, _gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook.
  6. Envios do react-router dev trazem a origem localhost, então contam como envios de teste. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.

Pegadinhas

ProblemaO que fazer
Uma inscrição no rodapé navega para /waitlist mesmo com JavaScript ligadoO formulário é um <Form> ou um <form> simples, não fetcher.Form. Use o fetcher e não devolva um redirect() da action; um redirect vindo da action de um fetcher navega.
O build falha com um erro sobre exports de actionssr está como false no react-router.config.ts. Mude para true, ou a lista de espera fica sem servidor para rodar.
O ref de toda inscrição é waitlistOs envios vêm da própria página /waitlist. Essa é a página de plano B; os links devem apontar para as suas páginas de verdade.
422 com unknown_fieldA definição de campos não lista ref. Adicione-o como na definição acima.
402 plan_requiredO workspace não tem plano e o envio veio de um site publicado. Ele fica retido, não perdido. Escolha um plano ou compre um pacote em Settings › Plan and usage e os envios retidos chegam.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.