{"id":"tanstack-start","name":"tanstack-start","summary":"Cloudflare WorkersでゼロからフルスタックのTanStack Startアプリを構築しましょう — SSR、ファイルベースのルーティング、サーバー機能、D1+Drizzle、better-auth、Tailwind v4+shadcn/ui。","body":"# TanStack Start on Cloudflare\n\nBuild a complete full-stack app from nothing. Claude generates every file — no template clone, no scaffold command.\n\nStack: 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).\n\n## Project File Tree\n\n```\nPROJECT_NAME/\n├── src/\n│   ├── routes/\n│   │   ├── __root.tsx              # Root layout (HTML shell, theme, CSS import)\n│   │   ├── index.tsx               # Landing / auth redirect\n│   │   ├── login.tsx               # Login page\n│   │   ├── register.tsx            # Register page\n│   │   ├── _authed.tsx             # Auth guard layout route\n│   │   ├── _authed/\n│   │   │   ├── dashboard.tsx       # Dashboard with stat cards\n│   │   │   ├── items.tsx           # Items list table\n│   │   │   ├── items.$id.tsx       # Edit item\n│   │   │   └── items.new.tsx       # Create item\n│   │   └── api/\n│   │       └── auth/\n│   │           └── $.ts            # better-auth API catch-all\n│   ├── components/\n│   │   ├── ui/                     # shadcn/ui components (auto-installed)\n│   │   ├── app-sidebar.tsx         # Navigation sidebar\n│   │   ├── theme-toggle.tsx        # Light/dark/system toggle\n│   │   ├── user-nav.tsx            # User dropdown menu\n│   │   └── stat-card.tsx           # Dashboard stat card\n│   ├── db/\n│   │   ├── schema.ts               # Drizzle schema (all tables)\n│   │   └── index.ts                # Drizzle client factory\n│   ├── lib/\n│   │   ├── auth.server.ts          # better-auth server config\n│   │   ├── auth.client.ts          # better-auth React hooks\n│   │   └── utils.ts                # cn() helper for shadcn/ui\n│   ├── server/\n│   │   └── functions.ts            # Server functions (CRUD, auth checks)\n│   ├── styles/\n│   │   └── app.css                 # Tailwind v4 + shadcn/ui CSS variables\n│   ├── router.tsx                  # TanStack Router configuration\n│   ├── client.tsx                  # Client entry (hydrateRoot)\n│   ├── ssr.tsx                     # SSR entry\n│   └── routeTree.gen.ts            # Auto-generated route tree (do not edit)\n├── drizzle/                        # Generated migrations\n├── public/                         # Static assets (favicon, etc.)\n├── vite.config.ts\n├── wrangler.jsonc\n├── drizzle.config.ts\n├── tsconfig.json\n├── package.json\n├── .dev.vars                       # Local env vars (NOT committed)\n└── .gitignore\n```\n\n## Dependencies\n\n**Runtime:**\n```json\n{\n  \"react\": \"^19.0.0\",\n  \"react-dom\": \"^19.0.0\",\n  \"@tanstack/react-router\": \"^1.120.0\",\n  \"@tanstack/react-start\": \"^1.120.0\",\n  \"drizzle-orm\": \"^0.38.0\",\n  \"better-auth\": \"^1.2.0\",\n  \"zod\": \"^3.24.0\",\n  \"class-variance-authority\": \"^0.7.0\",\n  \"clsx\": \"^2.1.0\",\n  \"tailwind-merge\": \"^3.0.0\",\n  \"lucide-react\": \"^0.480.0\"\n}\n```\n\n**Dev:**\n```json\n{\n  \"@cloudflare/vite-plugin\": \"^1.0.0\",\n  \"@tailwindcss/vite\": \"^4.0.0\",\n  \"@vitejs/plugin-react\": \"^4.4.0\",\n  \"tailwindcss\": \"^4.0.0\",\n  \"typescript\": \"^5.7.0\",\n  \"drizzle-kit\": \"^0.30.0\",\n  \"wrangler\": \"^4.0.0\",\n  \"tw-animate-css\": \"^1.2.0\"\n}\n```\n\n**Scripts:**\n```json\n{\n  \"dev\": \"vite\",\n  \"build\": \"vite build\",\n  \"preview\": \"vite preview\",\n  \"deploy\": \"wrangler deploy\",\n  \"db:generate\": \"drizzle-kit generate\",\n  \"db:migrate:local\": \"wrangler d1 migrations apply PROJECT_NAME-db --local\",\n  \"db:migrate:remote\": \"wrangler d1 migrations apply PROJECT_NAME-db --remote\"\n}\n```\n\n## Workflow\n\n### Step 1: Gather Project Info\n\n| Required | Optional |\n|----------|----------|\n| Project name (kebab-case) | Google OAuth credentials |\n| One-line description | Custom domain |\n| Cloudflare account | R2 storage needed? |\n| Auth method: Google OAuth, email/password, or both | Admin email |\n\n### Step 2: Initialise Project\n\nCreate the project directory and all config files from scratch.\n\n**`vite.config.ts`** — Plugin order matters. Cloudflare MUST be first:\n\n```typescript\nimport { defineConfig } from \"vite\";\nimport { cloudflare } from \"@cloudflare/vite-plugin\";\nimport { tanstackStart } from \"@tanstack/react-start/plugin/vite\";\nimport tailwindcss from \"@tailwindcss/vite\";\nimport viteReact from \"@vitejs/plugin-react\";\n\nexport default defineConfig({\n  plugins: [\n    cloudflare({ viteEnvironment: { name: \"ssr\" } }),\n    tailwindcss(),\n    tanstackStart(),\n    viteReact(),\n  ],\n});\n```\n\n**`wrangler.jsonc`**:\n\n```jsonc\n{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"name\": \"PROJECT_NAME\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"compatibility_flags\": [\"nodejs_compat\"],\n  \"main\": \"@tanstack/react-start/server-entry\",\n  \"account_id\": \"ACCOUNT_ID\",\n  \"d1_databases\": [\n    {\n      \"binding\": \"DB\",\n      \"database_name\": \"PROJECT_NAME-db\",\n      \"database_id\": \"DATABASE_ID\",\n      \"migrations_dir\": \"drizzle\"\n    }\n  ]\n}\n```\n\nKey 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.\n\n**`tsconfig.json`**:\n\n```json\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"paths\": { \"@/*\": [\"./src/*\"] },\n    \"types\": [\"@cloudflare/workers-types/2023-07-01\"]\n  },\n  \"include\": [\"src/**/*\", \"vite.config.ts\"]\n}\n```\n\n**`.dev.vars`** — generate `BETTER_AUTH_SECRET` with `openssl rand -hex 32`:\n\n```\nBETTER_AUTH_SECRET=<generated-hex-32>\nBETTER_AUTH_URL=http://localhost:3000\nTRUSTED_ORIGINS=http://localhost:3000\n# GOOGLE_CLIENT_ID=\n# GOOGLE_CLIENT_SECRET=\n```\n\n**`.gitignore`** — node_modules, .wrangler, dist, .output, .dev.vars, .vinxi, .DS_Store\n\nThen install and create the D1 database:\n\n```bash\ncd PROJECT_NAME && pnpm install\nnpx wrangler d1 create PROJECT_NAME-db\n# Copy the database_id into wrangler.jsonc d1_databases binding\n```\n\n### Step 3: Database Schema\n\n**`src/db/schema.ts`** — All tables. better-auth requires: `users`, `sessions`, `accounts`, `verifications`. Add application tables (e.g. `items`) for CRUD demo.\n\nD1-specific rules:\n- Use `integer` for timestamps (Unix epoch), NOT Date objects\n- Use `text` for primary keys (nanoid/cuid2), NOT autoincrement\n- Keep bound parameters under 100 per query (batch large inserts)\n- Foreign keys are always ON in D1\n\n**`src/db/index.ts`** — Drizzle client factory:\n\n```typescript\nimport { drizzle } from \"drizzle-orm/d1\";\nimport { env } from \"cloudflare:workers\";\nimport * as schema from \"./schema\";\n\nexport function getDb() {\n  return drizzle(env.DB, { schema });\n}\n```\n\n**CRITICAL**: Use `import { env } from \"cloudflare:workers\"` — NOT `process.env`. Create the Drizzle client inside each server function (per-request), not at module level.\n\n**`drizzle.config.ts`**:\n\n```typescript\nimport { defineConfig } from \"drizzle-kit\";\n\nexport default defineConfig({\n  schema: \"./src/db/schema.ts\",\n  out: \"./drizzle\",\n  dialect: \"sqlite\",\n});\n```\n\nGenerate and apply the initial migration:\n\n```bash\npnpm db:generate\npnpm db:migrate:local\n```\n\n### Step 4: Configure Auth\n\n**`src/lib/auth.server.ts`** — Server-side better-auth:\n\n```typescript\nimport { betterAuth } from \"better-auth\";\nimport { drizzleAdapter } from \"better-auth/adapters/drizzle\";\nimport { drizzle } from \"drizzle-orm/d1\";\nimport { env } from \"cloudflare:workers\";\nimport * as schema from \"../db/schema\";\n\nexport function getAuth() {\n  const db = drizzle(env.DB, { schema });\n  return betterAuth({\n    database: drizzleAdapter(db, { provider: \"sqlite\" }),\n    secret: env.BETTER_AUTH_SECRET,\n    baseURL: env.BETTER_AUTH_URL,\n    trustedOrigins: env.TRUSTED_ORIGINS?.split(\",\") ?? [],\n    emailAndPassword: { enabled: true },\n    socialProviders: {\n      // Add Google OAuth if credentials provided\n    },\n  });\n}\n```\n\n**CRITICAL**: `getAuth()` must be called per-request (inside handler/loader), NOT at module level.\n\n**`src/lib/auth.client.ts`** — Client-side auth hooks:\n\n```typescript\nimport { createAuthClient } from \"better-auth/react\";\n\nexport const { useSession, signIn, signOut, signUp } = createAuthClient();\n```\n\n**`src/routes/api/auth/$.ts`** — API catch-all for better-auth:\n\n```typescript\nimport { createAPIFileRoute } from \"@tanstack/react-start/api\";\nimport { getAuth } from \"../../../lib/auth.server\";\n\nexport const APIRoute = createAPIFileRoute(\"/api/auth/$\")({\n  GET: ({ request }) => getAuth().handler(request),\n  POST: ({ request }) => getAuth().handler(request),\n});\n```\n\n**CRITICAL**: Auth MUST use an API route (`createAPIFileRoute`), NOT a server function (`createServerFn`). better-auth needs direct request/response access.\n\n### Step 5: Server Functions\n\n**Core pattern** — always create DB client inside the handler:\n\n```typescript\nimport { createServerFn } from \"@tanstack/react-start\";\nimport { getDb } from \"../db\";\n\nexport const getItems = createServerFn({ method: \"GET\" }).handler(async () => {\n  const db = getDb();\n  return db.select().from(items).all();\n});\n```\n\n**Input validation** with Zod:\n\n```typescript\nexport const createItem = createServerFn({ method: \"POST\" })\n  .inputValidator(\n    z.object({\n      name: z.string().min(1),\n      description: z.string().optional(),\n    })\n  )\n  .handler(async ({ data }) => {\n    const db = getDb();\n    const id = crypto.randomUUID();\n    await db.insert(items).values({ id, ...data, createdAt: Date.now() });\n    return { id };\n  });\n```\n\n**Protected server functions** — check auth, throw redirect if unauthenticated:\n\n```typescript\nimport { redirect } from \"@tanstack/react-router\";\nimport { getAuth } from \"../lib/auth.server\";\n\nasync function requireSession(request?: Request) {\n  const auth = getAuth();\n  const session = await auth.api.getSession({\n    headers: request?.headers ?? new Headers(),\n  });\n  if (!session) {\n    throw redirect({ to: \"/login\" });\n  }\n  return session;\n}\n\nexport const getSessionFn = createServerFn({ method: \"GET\" }).handler(\n  async ({ request }) => {\n    const auth = getAuth();\n    return auth.api.getSession({ headers: request.headers });\n  }\n);\n\nexport const getItems = createServerFn({ method: \"GET\" }).handler(\n  async ({ request }) => {\n    const session = await requireSession(request);\n    const db = getDb();\n    return db.select().from(items).where(eq(items.userId, session.user.id)).all();\n  }\n);\n```\n\n**Route loader pattern** — server functions in route `loader`:\n\n```typescript\nexport const Route = createFileRoute(\"/_authed/items\")({\n  loader: () => getItems(),\n  component: ItemsPage,\n});\n\nfunction ItemsPage() {\n  const items = Route.useLoaderData();\n  return <div>{items.map((item) => <div key={item.id}>{item.name}</div>)}</div>;\n}\n```\n\n**Auth guard** (`_authed.tsx`) — use `beforeLoad`:\n\n```typescript\nexport const Route = createFileRoute(\"/_authed\")({\n  beforeLoad: async () => {\n    const session = await getSessionFn();\n    if (!session) {\n      throw redirect({ to: \"/login\" });\n    }\n    return { session };\n  },\n});\n```\n\nChild routes access session via `Route.useRouteContext()`.\n\n**Mutation + invalidation** — after mutations, invalidate router to refetch loaders:\n\n```typescript\nfunction CreateItemForm() {\n  const router = useRouter();\n  const handleSubmit = async (data: NewItem) => {\n    await createItem({ data });\n    router.invalidate();\n    router.navigate({ to: \"/items\" });\n  };\n  return <form onSubmit={...}>...</form>;\n}\n```\n\n**Type safety** — use Drizzle's `InferSelectModel` / `InferInsertModel` for server function input/output types. For auth failures, always use `throw redirect()` — not error responses.\n\n### Step 6: App Shell + Theme\n\n**`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.\n\n**`src/styles/app.css`** — `@import \"tailwindcss\"` (v4 syntax) + shadcn/ui CSS variables in `:root` and `.dark`. Semantic tokens only.\n\n**`src/router.tsx`**:\n\n```typescript\nimport { createRouter as createTanStackRouter } from \"@tanstack/react-router\";\nimport { routeTree } from \"./routeTree.gen\";\n\nexport function createRouter() {\n  return createTanStackRouter({ routeTree });\n}\n\ndeclare module \"@tanstack/react-router\" {\n  interface Register {\n    router: ReturnType<typeof createRouter>;\n  }\n}\n```\n\n**`src/client.tsx`** + **`src/ssr.tsx`** — standard TanStack Start entry boilerplate.\n\nInstall shadcn/ui:\n\n```bash\npnpm dlx shadcn@latest init --defaults\npnpm dlx shadcn@latest add button card input label sidebar table dropdown-menu form separator sheet\n```\n\n**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.\n\n**Components** in `src/components/`: `app-sidebar.tsx`, `theme-toggle.tsx`, `user-nav.tsx`, `stat-card.tsx`.\n\n### Step 7: CRUD Server Functions\n\n| Function | Method | Purpose |\n|----------|--------|---------|\n| `getItems` | GET | List all items for current user |\n| `getItem` | GET | Get single item by ID |\n| `createItem` | POST | Create new item |\n| `updateItem` | POST | Update existing item |\n| `deleteItem` | POST | Delete item by ID |\n\nEach 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()`.\n\n### Step 8: Verify Locally\n\n```bash\npnpm dev\n```\n\n- [ ] App loads at http://localhost:3000\n- [ ] Register a new account (email/password)\n- [ ] Login and logout work\n- [ ] Dashboard loads with stat cards\n- [ ] Create, list, edit, delete items\n- [ ] Theme toggle cycles: light -> dark -> system\n- [ ] Sidebar collapses on mobile\n- [ ] No console errors\n\n### Step 9: Deploy to Production\n\n**Pre-deploy checklist** — verify before running deploy:\n- [ ] `wrangler.jsonc` has correct `account_id`; `main` is `\"@tanstack/react-start/server-entry\"`; `nodejs_compat` in `compatibility_flags`\n- [ ] D1 database created and `database_id` set\n- [ ] `.dev.vars` is gitignored; no hardcoded secrets in source\n\n**Set production secrets:**\n\n```bash\nopenssl rand -hex 32 | npx wrangler secret put BETTER_AUTH_SECRET\necho \"https://PROJECT.SUBDOMAIN.workers.dev\" | npx wrangler secret put BETTER_AUTH_URL\necho \"http://localhost:3000,https://PROJECT.SUBDOMAIN.workers.dev\" | npx wrangler secret put TRUSTED_ORIGINS\n\n# Google OAuth (optional)\necho \"your-client-id\" | npx wrangler secret put GOOGLE_CLIENT_ID\necho \"your-client-secret\" | npx wrangler secret put GOOGLE_CLIENT_SECRET\n```\n\nIf using Google OAuth, add the production redirect URI in Google Cloud Console: `https://PROJECT.SUBDOMAIN.workers.dev/api/auth/callback/google`.\n\n**Migrate and deploy:**\n\n```bash\npnpm db:migrate:remote\npnpm build && npx wrangler deploy\n```\n\nAfter first deploy, update `BETTER_AUTH_URL` to the actual Worker URL and redeploy.\n\n**Verify:** app loads at production URL, auth works, CRUD works, theme persists.\n\n**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.\n\n## Common Issues\n\n| Symptom | Cause | Fix |\n|---------|-------|-----|\n| `env` is undefined | Accessed at module level | Use `import { env } from \"cloudflare:workers\"` inside request handler only |\n| D1 database not found | Binding mismatch | Check `d1_databases` binding name in wrangler.jsonc matches code |\n| Auth redirect loop | URL mismatch | `BETTER_AUTH_URL` must match actual URL exactly (protocol + domain, no trailing slash) |\n| Auth silently fails | Missing origins | Set `TRUSTED_ORIGINS` secret with all valid URLs (comma-separated) |\n| Styles not loading | Missing plugin | Ensure `@tailwindcss/vite` plugin is in vite.config.ts |\n| SSR hydration mismatch | Theme flash | Add `suppressHydrationWarning` to `<html>` element |\n| Build fails on Cloudflare | Bad config | Check `nodejs_compat` flag and `main` field in wrangler.jsonc |\n| Secrets not taking effect | No redeploy | `wrangler secret put` does NOT redeploy — run `npx wrangler deploy` after |\n| Auth endpoints return 404 | Wrong route type | Use `createAPIFileRoute` (API route), not `createServerFn` for better-auth |\n| \"redirect_uri_mismatch\" | Missing URI | Add production URL to Google Cloud Console OAuth redirect URIs |\n| Cryptic Vite errors | Plugin order | Must be: `cloudflare()` -> `tailwindcss()` -> `tanstackStart()` -> `viteReact()` |\n| \"Table not found\" 500s | Missing migration | Run `pnpm db:migrate:remote` before deploying |","author":"@jezweb","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/jezweb/claude-skills/tree/main/plugins/cloudflare/skills/tanstack-start","license":"MIT","category":"design","lang":"en","tokens":4516,"stars":0,"calls30d":1,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":["project.subdomain.workers.dev"]}}