TanStack Start + Convex + Better Auth + Resend, wired end-to-end. Email + password and username sign-in, OTP verification, avatar uploads, rate-limited HTTP API, SSR auth. Vite 8 with Rolldown+Oxc, Tailwind v4, shadcn/ui base-luma on Base UI, Oxlint + Oxfmt, Vitest 4, React 19, TypeScript 6. Use any PM: bun, pnpm, npm, or yarn.
Live demo: https://tanvex-demo.vercel.app
Needs Node 22.12+ or Bun, a Convex account (free tier), and a Resend API key (re_..., free tier is 3k/month).
git clone https://github.com/ramonclaudio/tanvex.git
cd tanvex
bun install # or pnpm install, npm install, yarn install
bun run setup # or pnpm setup, npm run setup, yarn setup
bun run dev # or pnpm dev, npm run dev, yarn devOpen http://localhost:3000. Sign up with a real email, you'll get an OTP from Resend.
For local Convex via Docker instead of cloud: bun run setup:local.
setup wipes node_modules and build artifacts, reinstalls deps with the detected PM, runs convex dev (creates a project, writes CONVEX_DEPLOYMENT and VITE_CONVEX_URL to .env.local, pushes functions, regenerates types), auto-generates BETTER_AUTH_SECRET, and prompts for your Resend key, sender, and app name. Re-running won't rotate existing Convex env vars; for one-off changes use bunx convex env set NAME VALUE.
dev Vite + Convex dev servers on :3000
dev:web vite dev only
convex:dev convex dev only
convex:codegen regenerate convex/_generated
convex:dashboard open the Convex dashboard
convex:logs tail deployment logs (:prod for production)
convex:env list deployment env vars (:prod for production)
build vite build && tsc --noEmit
start Nitro SSR server from .output/
preview vite preview
analyze ANALYZE=1 vite build with rollup visualizer
typecheck tsc --noEmit
lint oxlint
lint:fix oxlint --fix (safe fixes only)
lint:fix:suggest oxlint --fix --fix-suggestions
lint:fix:dangerous oxlint --fix --fix-suggestions --fix-dangerously
fmt oxfmt
fmt:check oxfmt --check
test vitest run
test:watch vitest
setup wipe, reinstall, configure Convex + Resend
setup:local same, with Docker Convex
setup:fresh provision a new Convex deployment
clean full reset: trash artifacts, reinstall, fmt, convex codegen, lint --fix, build, typecheck, test
Invoke with your package manager: npm run <name>, pnpm <name>, bun run <name>, or yarn <name>. The clean and setup scripts auto-detect which one and reinstall accordingly.
npx shadcn@latest add sheet dialog tabs
# or: pnpm dlx, bunx, yarn dlxComponents land in src/components/ui/. Import via the @/ alias:
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"base-luma is pinned in components.json, so every new component picks it up. The full design system (colors, typography, radii, component recipes) is in DESIGN.md; lint with npx @google/design.md lint DESIGN.md.
Ships delivery events (delivered, bounced, complained) back to Convex. Auth works without it, you just lose visibility on mail delivery.
- Run
setupfirst so the Convex project exists - Go to resend.com/webhooks, point at
https://<project>.convex.site/resend-webhook - Copy the signing secret
bunx convex env set RESEND_WEBHOOK_SECRET <secret>
Update before you deploy.
Project metadata (src/lib/site.ts):
SITE_URL # canonical URL, og:url, sitemap entries
SITE_NAME # og:site_name, PWA title, JSON-LD name
SITE_TITLE # default page title
SITE_DESCRIPTION # meta description, OG description
AUTHOR_NAME, AUTHOR_URL, AUTHOR_TWITTER, AUTHOR_GITHUB, REPO_URL
Package metadata (package.json):
name, description, author, homepage, repository, bugs, keywords
SEO files:
public/robots.txt—Sitemap:linepublic/sitemap.xml—<loc>entriespublic/.well-known/security.txt—Contact:andCanonical:public/llms.txt,public/llms-full.txt
Cloudflare worker name (wrangler.toml):
name = "your-worker"
Find anything missed:
grep -r "ramonclaudio/tanvex\|tanvex-demo\.vercel\.app" -l --exclude-dir=node_modulessrc/lib/site.ts reads import.meta.env.VITE_SITE_URL with a http://localhost:3000 fallback. Two ways to set it for production:
- Edit
src/lib/site.ts— change the fallback to your domain. Simplest, no env var needed. - Set
VITE_SITE_URLin your platform's env vars — keeps the source untouched, lets each environment override independently.
If you skip both, SEO meta will point at localhost. Search engines and social cards will be wrong.
.github/workflows/ci.yml runs on every push to main and every PR. For each of bun, pnpm, npm, yarn: install, typecheck, lint, fmt:check, test, build. Any failure on any PM blocks merge. Deploy success is verified by each platform's native check (Vercel commit status, Cloudflare Workers Builds).
Two parts: a Convex backend and a frontend host. Nitro auto-detects the host from build env (VERCEL, NETLIFY, Cloudflare Workers) and emits the right output. Security headers ship platform-neutral via Nitro route rules in vite.config.ts. Build commands and the bun version are pinned in the shipped vercel.json, netlify.toml, and wrangler.toml, no edits needed. VITE_* env vars are build-time, set them in the platform's env vars before deploys.
bunx convex deploy --cmd "bun run build" # provisions prod
bunx convex env set SITE_URL https://your-app.example.com --prod
bunx convex env set BETTER_AUTH_SECRET $(openssl rand -base64 32) --prod
bunx convex env set RESEND_API_KEY re_your_key --prod
bunx convex env set EMAIL_FROM "Your App <noreply@yourdomain.com>" --prod
bunx convex env set APP_NAME "Your App" --prod
bunx convex env set RESEND_TEST_MODE false --prodbun run setup wired the dev deployment locally. SITE_URL is the canonical frontend host.
Set these on every host (production environment):
CONVEX_DEPLOYMENT prod:your-project
VITE_CONVEX_URL https://your-project.convex.cloud
VITE_CONVEX_SITE_URL https://your-project.convex.site
SITE_URL https://your-app.example.com
VITE_SITE_URL same as SITE_URL
Ships vercel.json (pins bun via installCommand, runs bun run build). Deploy via vercel.com/new (import repo, add the env vars above under Project Settings → Environment Variables) or:
npx vercel link
npx vercel --prod
npx vercel env add VITE_SITE_URL production # repeat per variableShips netlify.toml (build command, publish dir, BUN_VERSION=1.3.14 pin). Deploy via app.netlify.com/start (connect repo, add the env vars above under Site Settings → Environment Variables) or:
npx netlify init
npx netlify deploy --prod
npx netlify env:set VITE_SITE_URL https://your-app.example.com --context productionShips wrangler.toml (compatibility_date, nodejs_compat, build command). Workers + Static Assets, not Pages (Pages reserves the ASSETS binding Nitro needs). Env vars must be set as build-time vars (Vite bakes import.meta.env.VITE_* at build), not runtime bindings. Deploy via dash.cloudflare.com → Workers & Pages → Create → Workers (connect repo, add the env vars above under Settings → Builds → Variables and secrets) or:
bunx wrangler login
VITE_SITE_URL=https://your-app.example.com bun run build
bunx wrangler deployAnywhere Nitro runs (Node, Bun, AWS Lambda, Deno Deploy, etc.): set NITRO_PRESET (e.g. node-server) and run bun run build. Output lands in .output/.
.
├── convex/ # backend
│ ├── auth.ts # Better Auth config, user helpers
│ ├── auth.config.ts # JWT for Convex-side auth checks
│ ├── constants.ts # client-safe constants (username + bio rules)
│ ├── convex.config.ts # component registration (better-auth, rate-limiter, resend)
│ ├── crons.ts # scheduled jobs
│ ├── email.ts # Resend helpers + OTP templates
│ ├── errors.ts # ConvexError factories with stable codes
│ ├── functions.ts # authQuery / authMutation / optionalAuthQuery wrappers
│ ├── http.ts # HTTP router with CORS
│ ├── origins.ts # SITE_URL + TRUSTED_ORIGINS parsing
│ ├── rateLimit.ts # token-bucket limiter config
│ ├── schema.ts # users table (identity merged from Better Auth)
│ ├── users.ts # profile queries and mutations
│ └── validators.ts # shared Convex validators
├── patches/ # *.patch files applied to node_modules via postinstall
├── scripts/
│ ├── _run.mjs # runtime-agnostic launcher (bun -> tsx -> npx tsx)
│ ├── apply-patches.mjs # postinstall: applies patches/*.patch via git apply
│ ├── clean.ts # `<pm> run clean`: full reset, fix + verify chain
│ └── setup.ts # one-command onboarding
└── src/
├── components/
│ ├── default-catch-boundary.tsx # router error boundary
│ ├── devtools.tsx # TanStack devtools (dev only)
│ ├── not-found.tsx # 404 page
│ ├── theme-provider.tsx # light/dark/system with no-flash script
│ ├── theme-toggle.tsx # dropdown toggle
│ ├── user-menu.tsx # avatar dropdown
│ ├── web-vitals.tsx # CLS/INP/LCP reporter
│ └── ui/ # shadcn/ui base-luma primitives
├── lib/
│ ├── auth-client.ts # Better Auth client
│ ├── auth-server.ts # server-side auth helpers
│ ├── convex-auth.tsx # Better Auth → Convex provider bridge
│ ├── seo.ts # head meta helper
│ ├── site.ts # SITE_URL, SITE_NAME, SITE_TITLE, AUTHOR_*
│ └── utils.ts # cn() class merger
├── routes/
│ ├── __root.tsx # shellComponent: html/body shell, header bar, theme, Toaster
│ ├── _authed.tsx # auth gate
│ ├── _authed/profile.tsx # profile editor + avatar upload + change password
│ ├── api/auth/ # Better Auth proxy for TanStack Start
│ ├── index.tsx # homepage
│ └── sign-in.tsx # auth UI: signin, signup, OTP verify, reset
├── router.tsx
├── routeTree.gen.ts # auto-generated by TanStack Router
├── styles.css # Tailwind v4 + base-luma + reduced-motion
└── vite-env.d.ts # typed import.meta.env
MIT.
