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.
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 envia | A rota atual, a menos que você defina action | A action da rota /waitlist |
| Depois do envio | Uma navegação. A URL e o histórico mudam | Nenhuma navegação. O visitante continua na página |
| O resultado | O actionData da rota que rodou | fetcher.data, compartilhado por todos os fetchers com a mesma key |
| Sem JavaScript | Um envio normal | Um 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.
- 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.
- 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.
- Sem JavaScript, o mesmo formulário envia para /waitlist como uma navegação normal.
- De qualquer jeito, a action de /waitlist confere o e-mail e encaminha a inscrição ao Ikiform com a chave secreta.
- Um fetcher lê o resultado em fetcher.data. Um envio simples cai na página /waitlist, que o mostra a partir do actionData.
Configure o projeto
O guia de servidor explica como criar o formulário e uma chave secreta. Os arquivos desta página:
app/
root.tsx
routes.ts
routes/home.tsx
routes/pricing.tsx
routes/waitlist.tsx
components/waitlist-form.tsx
react-router.config.ts
.envColoque 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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
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
Origindo navegador é repassado porque o Ikiform conta os envios de teste gratuitos delocalhostpor ele, e ofetchda própria action não envia nenhum. refprecisa ser formado por letras minúsculas, dígitos,-ou_, até 40 caracteres, o mesmomax_lengthda definição. Qualquer outra coisa é armazenada comodirect.- O default export é o plano B sem JavaScript. Ele renderiza o mesmo formulário com o resultado da action.
O formulário compartilhado
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. OJoinedNoteno header lê o mesmofetcher.data, então muda no instante em que o formulário do rodapé dá certo.sourceFortransforma/blog/launchemblog-launche/emhome. O caminho é conhecido no servidor, então funciona também em páginas pré-renderizadas.fetcher.stateésubmittingenquanto o envio roda, depoisloadingenquanto 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:
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:
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.
| Host | O que adicionar | Lendo a chave |
|---|---|---|
| Vercel | @vercel/react-router, com presets: [vercelPreset()] no react-router.config.ts | process.env, definida em Environment Variables |
| Netlify | @netlify/vite-plugin-react-router, como netlifyReactRouter() nos plugins do Vite | process.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
- Rode
react-router dev, abrahttp://localhost:5173/pricinge inscreva-se no rodapé. A frase de agradecimento substitui o formulário, e o aviso do header muda junto. - Abra Submissions no painel. A inscrição tem
refdefinido comopricing. - Desligue o JavaScript e inscreva-se pela página inicial. Você cai em
/waitlistcom a frase de agradecimento, e a inscrição temrefdefinido comohome. - Envie
a@bpelo rodapé. O erro aparece abaixo do input com o endereço ainda digitado. - 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. - Envios do
react-router devtrazem a origemlocalhost, 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 delocalhost. 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
| Problema | O que fazer |
|---|---|
Uma inscrição no rodapé navega para /waitlist mesmo com JavaScript ligado | O 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 action | ssr 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 é waitlist | Os 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_field | A definição de campos não lista ref. Adicione-o como na definição acima. |
402 plan_required | O 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
Use o useFetcher e renderize o fetcher.Form com a action da rota que o trata. O visitante continua na página e o resultado chega em fetcher.data. Antes da hidratação, ele envia como um formulário normal.
Sim, quando ssr é true. Páginas pré-renderizadas são servidas como HTML, e os formulários delas enviam para uma action que roda no servidor. Com ssr definido como false, actions não são permitidas.
Coloque o caminho da página num input oculto ref, como o sourceFor faz acima. O caminho é conhecido no servidor e no cliente, então funciona em páginas pré-renderizadas e antes da hidratação.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.