Build a working web app on the user's own accounts and put it live on their Cloudflare domain. Do every step yourself with the CLIs; only ask for what needs a human (a browser login, a device code, a payment, a secret key). Talk to the user in their language, short messages, no jargon.
Stack (do not swap a layer unless the user asks)
| layer | tool |
|---|---|
| app, routing, server functions | TanStack Start (React, TypeScript, Vite) |
| UI | shadcn/ui on Base UI + Tailwind CSS v4, lucide-react icons, sonner toasts |
| database, backend, files, crons | Convex, for everything: no other database, no other API server |
| auth | Better Auth through @convex-dev/better-auth, email OTP by default |
| marketing and login emails | Lumail through @lumail/convex |
| payments (only if the app sells) | Stripe Checkout, see the stripe-checkout skill |
| video | Tella embeds (the user pastes the Tella URL) |
| hosting, domain, DNS | Cloudflare Workers with the @cloudflare/vite-plugin, deployed with cf |
| code | Git + one private GitHub repo |
TanStack for every client job
Reach for the TanStack library before any other package:
| job | package |
|---|---|
| fetching, cache, loading states | @tanstack/react-query + @convex-dev/react-query (convexQuery(api.x.y, args)) |
| lists, tables, sorting, filters, pagination | @tanstack/react-table |
| long lists (hundreds of rows) | @tanstack/react-virtual |
| forms and validation | @tanstack/react-form + zod |
| render markdown | @tanstack/markdown |
keyboard shortcuts (cmd+k, j/k, esc) |
@tanstack/react-hotkeys |
| debounce search, throttle saves | @tanstack/react-pacer |
| AI chat or generation in the app | @tanstack/ai-react |
Check the current API of each package with the docs before using it (npm view <pkg> version, then its README or the find-docs skill). Never guess an API from memory.
1. Check access before writing code
git --version && gh auth status # GitHub
cf --version && cf auth whoami # Cloudflare (npm i -g cf, then cf auth login)
npx convex --version # Convex (npx convex login when building)
npx lumail@latest auth test # Lumail (npx lumail@latest setup --authenticate)
Fix what fails first. When a login needs the browser, give the exact link or code and wait for "done". Secrets go only in .env.local, npx convex env set or Cloudflare secrets, never in a committed file.
2. Grill, then build
Always run the grill-me skill before writing any code, even when the brief looks clear: it asks the user about the app they want (who it is for, the main action, the data, who sees what, money, emails, domain, look, v1 scope, what "done" means), with a recommended answer for each question. Build only once the user has confirmed the final brief. Skip it only if the user already confirmed a brief in this conversation. Defaults: landing at /, app at /app, English UI, OTP sign-in, the user is the only admin.
- Scaffold: TanStack Start + Tailwind +
npx shadcn@latest init(Base UI flavor), add the components you need withnpx shadcn@latest add.git init,gh repo create <name> --private --source . --push. - Convex:
npx convex dev --oncecreates the project and writes.env.local. Oneschema.tswith an index for every query. Every public function checks the signed-in user and ownership server-side; anything only the server calls isinternal*. Bound reads with.take(n)or.paginate(). See theconvexskill. - Better Auth: register both components in
convex/convex.config.ts(app.use(betterAuth),app.use(lumail)),createAuthwithauthComponent.adapter(ctx), theemailOTPplugin and theconvex({ authConfig })plugin, routes viaauthComponent.registerRoutes(http, createAuth).BETTER_AUTH_SECRETandSITE_URLas Convex env vars. Protect/appin the routebeforeLoad, and again in every Convex function. - Lumail: see the next section.
- Data on screen: queries through
useSuspenseQuery(convexQuery(...))so the UI updates live, lists in TanStack Table, forms in TanStack Form calling Convex mutations, toasts on success and error. - Payments (only if the app sells): start with a fake checkout until the user gives Stripe test keys, then real Checkout + a signed webhook in
convex/http.tsthat grants access. Test card4242 4242 4242 4242, live keys only once the test flow works. See thestripe-checkoutskill. - Clean error page and 404 page, a favicon, page titles.
3. Lumail, with skip double opt-in by default
One convex/email.ts holds every Lumail call; the API key (LUMAIL_API_KEY, created by you with lumail tokens --help) never reaches the browser.
- Login codes:
sendVerificationOTPschedules an internal mutation that callslumail.sendEmail(ctx, { ..., transactional: true }). Without a key, log the code to the Convex console so local dev still works. - Signups: in Better Auth
databaseHooks.user.create.after, upsert the person withlumail.upsertSubscriber(ctx, { email, tags: ["<app-slug>-user"], triggerWorkflows: true }). - Skip double opt-in = true as the general rule for anyone who signs in or buys: the OTP or the payment already proves they own the email, so they go straight to "subscribed" and tag workflows fire right away. The component cannot pass that flag, so also call the API from an internal action:
await fetch("https://lumail.io/api/v1/subscribers", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.LUMAIL_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ email, skipDoubleOptIn: true }),
});
- Keep double opt-in only for a cold form (newsletter or waitlist where the email is not verified).
- Sending domain: create it with
lumail domain --help, then add the DNS records it returns in Cloudflare yourself (unproxied). More in thelumailskill.
4. Deploy on Cloudflare
wrangler.jsonc:main: "@tanstack/react-start/server-entry",compatibility_flags: ["nodejs_compat"],SITE_URLinvars, the custom domain inrouteswithcustom_domain: true.npx convex deployfor the production backend, set prod env vars withnpx convex env set --prod, and putVITE_CONVEX_URL/VITE_CONVEX_SITE_URLof prod in the build env.- Build and deploy with
cf(cf build,cf deploy, orcf cli search "deploy worker"), never by hand in the dashboard. See thecloudflareskill. - The domain must be in the user's Cloudflare account. If it is elsewhere, tell them to move the name servers or buy it in Cloudflare.
5. Prove it works
Open the live URL and walk the real flows: landing, sign in with a real OTP email, the main action of the app, a list with real rows, a test purchase if the app sells, a keyboard shortcut, sign out, the 404 page. Check the person shows up in Lumail as subscribed with the right tag. Fix every error you see, then run audit-safety on auth, data access and payments before saying it is live.
Rules
- "Fais-le toi-même": use every CLI and API before asking the user to click something.
- Never print, commit or log secrets.
- Reply with what is live, the URL, and what is left to do.