Build a full-stack TanStack Start app on Cloudflare Workers from scratch โ SSR, file-based routing, server functions, D1+Drizzle, better-auth, Tailwind v4+shadcn/ui. Use whenever the user mentions TanStack Start, asks to scaffold a full-stack Cloudflare app with SSR, wants an
Installs just this skill. Get the whole plugin for auto-invocation.
โก How it fires
How this skill gets triggered: by you, by Claude, or both.
Fires itselfClaude auto-loads it when your prompt matches the work.
You can call itInvoke it directly when you want it.
Slash command/tanstack-start
๐๏ธ Context preview
The summary Claude sees to decide when to auto-load this skill.
Build a full-stack TanStack Start app on Cloudflare Workers from scratch โ SSR, file-based routing, server functions, D1+Drizzle, better-auth, Tailwind v4+shadcn/ui. Use whenever the user mentions TanStack Start, asks to scaffold a full-stack Cloudflare app with SSR, wants an
๐ Stats
Stars940
Forks96
LanguagePython
LicenseMIT
๐ฆ Ships with jezweb-skills
</> SKILL.md
tanstack-start.SKILL.md
---name: tanstack-start
description: "Build a full-stack TanStack Start app on Cloudflare Workers from scratch โ SSR, file-based routing, server functions, D1+Drizzle, better-auth, Tailwind v4+shadcn/ui. Use whenever the user mentions TanStack Start, asks to scaffold a full-stack Cloudflare app with SSR, wants an SSR dashboard, or asks for a React 19 + Cloudflare Workers app with file-based routing and server functions โ even if they don't name TanStack Start specifically. No template repo โ Claude generates every file fresh per project."
compatibility: claude-code-only
---# TanStack Start on Cloudflare
Build a complete full-stack app from nothing. Claude generates every file โ no template clone, no scaffold command.
Stack: TanStack Start v1 (SSR, file-based routing, server functions via Nitro) on Cloudflare Workers; React 19 + Tailwind v4 + shadcn/ui; D1 + Drizzle; better-auth (Google OAuth + email/password).
## Project File Tree
```
PROJECT_NAME/
โโโ src/
โ โโโ routes/
โ โ โโโ __root.tsx # Root layout (HTML shell, theme, CSS import)
โ โ โโโ index.tsx # Landing / auth redirect
โ โ โโโ login.tsx # Login page
โ โ โโโ register.tsx # Register page
โ โ โโโ _authed.tsx # Auth guard layout route
โ โ โโโ _authed/
โ โ โ โโโ dashboard.tsx # Dashboard with stat cards
Key points: `main` MUST be `"@tanstack/react-start/server-entry"` (Nitro server entry). Use `nodejs_compat` (NOT `node_compat`). Add `account_id` to avoid interactive prompts.
**`tsconfig.json`**:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": { "@/*": ["./src/*"] },
"types": ["@cloudflare/workers-types/2023-07-01"]
},
"include": ["src/**/*", "vite.config.ts"]
}
```
**`.dev.vars`** โ generate `BETTER_AUTH_SECRET` with `openssl rand -hex 32`:
# Copy the database_id into wrangler.jsonc d1_databases binding
```
### Step 3: Database Schema
**`src/db/schema.ts`** โ All tables. better-auth requires: `users`, `sessions`, `accounts`, `verifications`. Add application tables (e.g. `items`) for CRUD demo.
D1-specific rules:
- Use `integer` for timestamps (Unix epoch), NOT Date objects
- Use `text` for primary keys (nanoid/cuid2), NOT autoincrement
- Keep bound parameters under 100 per query (batch large inserts)
- Foreign keys are always ON in D1
**`src/db/index.ts`** โ Drizzle client factory:
```typescript
import { drizzle } from "drizzle-orm/d1";
import { env } from "cloudflare:workers";
import * as schema from "./schema";
export function getDb() {
return drizzle(env.DB, { schema });
}
```
**CRITICAL**: Use `import { env } from "cloudflare:workers"` โ NOT `process.env`. Create the Drizzle client inside each server function (per-request), not at module level.
**CRITICAL**: Auth MUST use an API route (`createAPIFileRoute`), NOT a server function (`createServerFn`). better-auth needs direct request/response access.
### Step 5: Server Functions
**Core pattern** โ always create DB client inside the handler:
```typescript
import { createServerFn } from "@tanstack/react-start";
Child routes access session via `Route.useRouteContext()`.
**Mutation + invalidation** โ after mutations, invalidate router to refetch loaders:
```typescript
function CreateItemForm() {
const router = useRouter();
const handleSubmit = async (data: NewItem) => {
await createItem({ data });
router.invalidate();
router.navigate({ to: "/items" });
};
return <form onSubmit={...}>...</form>;
}
```
**Type safety** โ use Drizzle's `InferSelectModel` / `InferInsertModel` for server function input/output types. For auth failures, always use `throw redirect()` โ not error responses.
### Step 6: App Shell + Theme
**`src/routes/__root.tsx`** โ Full HTML document with `<HeadContent />` + `<Scripts />` from `@tanstack/react-router`, `suppressHydrationWarning` on `<html>` (SSR + theme), inline theme init script to prevent flash, global CSS import.
**`src/styles/app.css`** โ `@import "tailwindcss"` (v4 syntax) + shadcn/ui CSS variables in `:root` and `.dark`. Semantic tokens only.
**`src/router.tsx`**:
```typescript
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function createRouter() {
return createTanStackRouter({ routeTree });
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof createRouter>;
}
}
```
**`src/client.tsx`** + **`src/ssr.tsx`** โ standard TanStack Start entry boilerplate.
**Theme toggle** โ three-state (light โ dark โ system โ light), localStorage-persisted, `.dark` class on `<html>`. **JS-only** system preference detection; NO CSS `@media (prefers-color-scheme)` queries.
**Components** in `src/components/`: `app-sidebar.tsx`, `theme-toggle.tsx`, `user-nav.tsx`, `stat-card.tsx`.
### Step 7: CRUD Server Functions
| Function | Method | Purpose |
|----------|--------|---------|
| `getItems` | GET | List all items for current user |
| `getItem` | GET | Get single item by ID |
| `createItem` | POST | Create new item |
| `updateItem` | POST | Update existing item |
| `deleteItem` | POST | Delete item by ID |
Each server function: (1) gets auth session, (2) creates per-request Drizzle client via `getDb()`, (3) performs DB operation, (4) returns typed data. Route loaders call GET functions. Mutations call POST functions then `router.invalidate()`.
### Step 8: Verify Locally
```bash
pnpm dev
```
- [ ] App loads at http://localhost:3000
- [ ] Register a new account (email/password)
- [ ] Login and logout work
- [ ] Dashboard loads with stat cards
- [ ] Create, list, edit, delete items
- [ ] Theme toggle cycles: light -> dark -> system
- [ ] Sidebar collapses on mobile
- [ ] No console errors
### Step 9: Deploy to Production
**Pre-deploy checklist** โ verify before running deploy:
- [ ] `wrangler.jsonc` has correct `account_id`; `main` is `"@tanstack/react-start/server-entry"`; `nodejs_compat` in `compatibility_flags`
- [ ] D1 database created and `database_id` set
- [ ] `.dev.vars` is gitignored; no hardcoded secrets in source
**Set production secrets:**
```bash
openssl rand -hex 32 | npx wrangler secret put BETTER_AUTH_SECRET
echo "https://PROJECT.SUBDOMAIN.workers.dev" | npx wrangler secret put BETTER_AUTH_URL
echo "http://localhost:3000,https://PROJECT.SUBDOMAIN.workers.dev" | npx wrangler secret put TRUSTED_ORIGINS
# Google OAuth (optional)
echo "your-client-id" | npx wrangler secret put GOOGLE_CLIENT_ID
echo "your-client-secret" | npx wrangler secret put GOOGLE_CLIENT_SECRET
```
If using Google OAuth, add the production redirect URI in Google Cloud Console: `https://PROJECT.SUBDOMAIN.workers.dev/api/auth/callback/google`.
**Migrate and deploy:**
```bash
pnpm db:migrate:remote
pnpm build && npx wrangler deploy
```
After first deploy, update `BETTER_AUTH_URL` to the actual Worker URL and redeploy.
**Verify:** app loads at production URL, auth works, CRUD works, theme persists.
**Custom domain** (optional): Cloudflare Dashboard โ Workers โ Triggers โ Custom Domains. Update `BETTER_AUTH_URL` + `TRUSTED_ORIGINS` secrets + Google OAuth redirect URI to the new domain. Redeploy.
## Common Issues
| Symptom | Cause | Fix |
|---------|-------|-----|
| `env` is undefined | Accessed at module level | Use `import { env } from "cloudflare:workers"` inside request handler only |
| D1 database not found | Binding mismatch | Check `d1_databases` binding name in wrangler.jsonc matches code |
| Auth redirect loop | URL mismatch | `BETTER_AUTH_URL` must match actual URL exactly (protocol + domain, no trailing slash) |
| Auth silently fails | Missing origins | Set `TRUSTED_ORIGINS` secret with all valid URLs (comma-separated) |
| Styles not loading | Missing plugin | Ensure `@tailwindcss/vite` plugin is in vite.config.ts |
| SSR hydration mismatch | Theme flash | Add `suppressHydrationWarning` to `<html>` element |
| Build fails on Cloudflare | Bad config | Check `nodejs_compat` flag and `main` field in wrangler.jsonc |
| Secrets not taking effect | No redeploy | `wrangler secret put` does NOT redeploy โ run `npx wrangler deploy` after |
| Auth endpoints return 404 | Wrong route type | Use `createAPIFileRoute` (API route), not `createServerFn` for better-auth |
| "redirect_uri_mismatch" | Missing URI | Add production URL to Google Cloud Console OAuth redirect URIs |
| Cryptic Vite errors | Plugin order | Must be: `cloudflare()` -> `tailwindcss()` -> `tanstackStart()` -> `viteReact()` |
| "Table not found" 500s | Missing migration | Run `pnpm db:migrate:remote` before deploying |