Claude Agent Skill · by Jezweb

Tanstack Start

Install Tanstack Start skill for Claude Code from jezweb/claude-skills.

Install
Terminal · npx
$npx skills add https://github.com/jezweb/claude-skills --skill tanstack-start
Works with Paperclip

How Tanstack Start fits into a Paperclip company.

Tanstack Start drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.

S
SaaS FactoryPaired

Pre-configured AI company — 18 agents, 18 skills, one-time purchase.

$27$59
Explore pack
Source file
SKILL.md543 lines
Expand
---name: tanstack-startdescription: "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. 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. Each project gets exactly what it needs. ## What You Get | Layer | Technology ||-------|-----------|| Framework | TanStack Start v1 (SSR, file-based routing, server functions) || Frontend | React 19, Tailwind v4, shadcn/ui || Backend | Server functions (via Nitro on Cloudflare Workers) || Database | D1 + Drizzle ORM || Auth | better-auth (Google OAuth + email/password) || Deployment | Cloudflare Workers | ## 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│   │   │   ├── items.tsx           # Items list table│   │   │   ├── items.$id.tsx       # Edit item│   │   │   └── items.new.tsx       # Create item│   │   └── api/│   │       └── auth/│   │           └── $.ts            # better-auth API catch-all│   ├── components/│   │   ├── ui/                     # shadcn/ui components (auto-installed)│   │   ├── app-sidebar.tsx         # Navigation sidebar│   │   ├── theme-toggle.tsx        # Light/dark/system toggle│   │   ├── user-nav.tsx            # User dropdown menu│   │   └── stat-card.tsx           # Dashboard stat card│   ├── db/│   │   ├── schema.ts               # Drizzle schema (all tables)│   │   └── index.ts                # Drizzle client factory│   ├── lib/│   │   ├── auth.server.ts          # better-auth server config│   │   ├── auth.client.ts          # better-auth React hooks│   │   └── utils.ts                # cn() helper for shadcn/ui│   ├── server/│   │   └── functions.ts            # Server functions (CRUD, auth checks)│   ├── styles/│   │   └── app.css                 # Tailwind v4 + shadcn/ui CSS variables│   ├── router.tsx                  # TanStack Router configuration│   ├── client.tsx                  # Client entry (hydrateRoot)│   ├── ssr.tsx                     # SSR entry│   └── routeTree.gen.ts            # Auto-generated route tree (do not edit)├── drizzle/                        # Generated migrations├── public/                         # Static assets (favicon, etc.)├── vite.config.ts├── wrangler.jsonc├── drizzle.config.ts├── tsconfig.json├── package.json├── .dev.vars                       # Local env vars (NOT committed)└── .gitignore``` ## Dependencies **Runtime:**```json{  "react": "^19.0.0",  "react-dom": "^19.0.0",  "@tanstack/react-router": "^1.120.0",  "@tanstack/react-start": "^1.120.0",  "drizzle-orm": "^0.38.0",  "better-auth": "^1.2.0",  "zod": "^3.24.0",  "class-variance-authority": "^0.7.0",  "clsx": "^2.1.0",  "tailwind-merge": "^3.0.0",  "lucide-react": "^0.480.0"}``` **Dev:**```json{  "@cloudflare/vite-plugin": "^1.0.0",  "@tailwindcss/vite": "^4.0.0",  "@vitejs/plugin-react": "^4.4.0",  "tailwindcss": "^4.0.0",  "typescript": "^5.7.0",  "drizzle-kit": "^0.30.0",  "wrangler": "^4.0.0",  "tw-animate-css": "^1.2.0"}``` **Scripts:**```json{  "dev": "vite",  "build": "vite build",  "preview": "vite preview",  "deploy": "wrangler deploy",  "db:generate": "drizzle-kit generate",  "db:migrate:local": "wrangler d1 migrations apply PROJECT_NAME-db --local",  "db:migrate:remote": "wrangler d1 migrations apply PROJECT_NAME-db --remote"}``` ## Workflow ### Step 1: Gather Project Info | Required | Optional ||----------|----------|| Project name (kebab-case) | Google OAuth credentials || One-line description | Custom domain || Cloudflare account | R2 storage needed? || Auth method: Google OAuth, email/password, or both | Admin email | ### Step 2: Initialise Project Create the project directory and all config files from scratch. **`vite.config.ts`** — Plugin order matters. Cloudflare MUST be first: ```typescriptimport { defineConfig } from "vite";import { cloudflare } from "@cloudflare/vite-plugin";import { tanstackStart } from "@tanstack/react-start/plugin/vite";import tailwindcss from "@tailwindcss/vite";import viteReact from "@vitejs/plugin-react"; export default defineConfig({  plugins: [    cloudflare({ viteEnvironment: { name: "ssr" } }),    tailwindcss(),    tanstackStart(),    viteReact(),  ],});``` **`wrangler.jsonc`**: ```jsonc{  "$schema": "node_modules/wrangler/config-schema.json",  "name": "PROJECT_NAME",  "compatibility_date": "2025-04-01",  "compatibility_flags": ["nodejs_compat"],  "main": "@tanstack/react-start/server-entry",  "account_id": "ACCOUNT_ID",  "d1_databases": [    {      "binding": "DB",      "database_name": "PROJECT_NAME-db",      "database_id": "DATABASE_ID",      "migrations_dir": "drizzle"    }  ]}``` 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`: ```BETTER_AUTH_SECRET=<generated-hex-32>BETTER_AUTH_URL=http://localhost:3000TRUSTED_ORIGINS=http://localhost:3000# GOOGLE_CLIENT_ID=# GOOGLE_CLIENT_SECRET=``` **`.gitignore`** — node_modules, .wrangler, dist, .output, .dev.vars, .vinxi, .DS_Store Then install and create the D1 database: ```bashcd PROJECT_NAME && pnpm installnpx wrangler d1 create PROJECT_NAME-db# 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: ```typescriptimport { 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. **`drizzle.config.ts`**: ```typescriptimport { defineConfig } from "drizzle-kit"; export default defineConfig({  schema: "./src/db/schema.ts",  out: "./drizzle",  dialect: "sqlite",});``` Generate and apply the initial migration: ```bashpnpm db:generatepnpm db:migrate:local``` ### Step 4: Configure Auth **`src/lib/auth.server.ts`** — Server-side better-auth: ```typescriptimport { betterAuth } from "better-auth";import { drizzleAdapter } from "better-auth/adapters/drizzle";import { drizzle } from "drizzle-orm/d1";import { env } from "cloudflare:workers";import * as schema from "../db/schema"; export function getAuth() {  const db = drizzle(env.DB, { schema });  return betterAuth({    database: drizzleAdapter(db, { provider: "sqlite" }),    secret: env.BETTER_AUTH_SECRET,    baseURL: env.BETTER_AUTH_URL,    trustedOrigins: env.TRUSTED_ORIGINS?.split(",") ?? [],    emailAndPassword: { enabled: true },    socialProviders: {      // Add Google OAuth if credentials provided    },  });}``` **CRITICAL**: `getAuth()` must be called per-request (inside handler/loader), NOT at module level. **`src/lib/auth.client.ts`** — Client-side auth hooks: ```typescriptimport { createAuthClient } from "better-auth/react"; export const { useSession, signIn, signOut, signUp } = createAuthClient();``` **`src/routes/api/auth/$.ts`** — API catch-all for better-auth: ```typescriptimport { createAPIFileRoute } from "@tanstack/react-start/api";import { getAuth } from "../../../lib/auth.server"; export const APIRoute = createAPIFileRoute("/api/auth/$")({  GET: ({ request }) => getAuth().handler(request),  POST: ({ request }) => getAuth().handler(request),});``` **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: ```typescriptimport { createServerFn } from "@tanstack/react-start";import { getDb } from "../db"; export const getItems = createServerFn({ method: "GET" }).handler(async () => {  const db = getDb();  return db.select().from(items).all();});``` **Input validation** with Zod: ```typescriptexport const createItem = createServerFn({ method: "POST" })  .inputValidator(    z.object({      name: z.string().min(1),      description: z.string().optional(),    })  )  .handler(async ({ data }) => {    const db = getDb();    const id = crypto.randomUUID();    await db.insert(items).values({ id, ...data, createdAt: Date.now() });    return { id };  });``` **Protected server functions** — check auth, throw redirect if unauthenticated: ```typescriptimport { redirect } from "@tanstack/react-router";import { getAuth } from "../lib/auth.server"; async function requireSession(request?: Request) {  const auth = getAuth();  const session = await auth.api.getSession({    headers: request?.headers ?? new Headers(),  });  if (!session) {    throw redirect({ to: "/login" });  }  return session;} export const getSessionFn = createServerFn({ method: "GET" }).handler(  async ({ request }) => {    const auth = getAuth();    return auth.api.getSession({ headers: request.headers });  }); export const getItems = createServerFn({ method: "GET" }).handler(  async ({ request }) => {    const session = await requireSession(request);    const db = getDb();    return db.select().from(items).where(eq(items.userId, session.user.id)).all();  });``` **Route loader pattern** — server functions in route `loader`: ```typescriptexport const Route = createFileRoute("/_authed/items")({  loader: () => getItems(),  component: ItemsPage,}); function ItemsPage() {  const items = Route.useLoaderData();  return <div>{items.map((item) => <div key={item.id}>{item.name}</div>)}</div>;}``` **Auth guard** (`_authed.tsx`) — use `beforeLoad`: ```typescriptexport const Route = createFileRoute("/_authed")({  beforeLoad: async () => {    const session = await getSessionFn();    if (!session) {      throw redirect({ to: "/login" });    }    return { session };  },});``` Child routes access session via `Route.useRouteContext()`. **Mutation + invalidation** — after mutations, invalidate router to refetch loaders: ```typescriptfunction 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`** — Root layout with full HTML document, `<HeadContent />` and `<Scripts />` from `@tanstack/react-router`. Add `suppressHydrationWarning` on `<html>` for SSR + theme toggle compatibility. Import global CSS. Include inline theme init script to prevent flash. **`src/styles/app.css`** — `@import "tailwindcss"` (v4 syntax), CSS variables for shadcn/ui tokens in `:root` and `.dark`, neutral/monochrome palette. Use semantic tokens only. **`src/router.tsx`**: ```typescriptimport { 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`** and **`src/ssr.tsx`** — standard TanStack Start entry point boilerplate. Install shadcn/ui (configure to use `src/components`): ```bashpnpm dlx shadcn@latest init --defaultspnpm dlx shadcn@latest add button card input label sidebar table dropdown-menu form separator sheet``` **Theme toggle**: three-state (light -> dark -> system -> light). Store in localStorage. Apply `.dark` class on `<html>`. Use JS-only system preference detection — NO CSS `@media (prefers-color-scheme)` queries. **Components** in `src/components/`: `app-sidebar.tsx` (navigation), `theme-toggle.tsx`, `user-nav.tsx` (dropdown with sign-out), `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 ```bashpnpm 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:**- [ ] `wrangler.jsonc` has correct `account_id`- [ ] D1 database created and `database_id` set- [ ] `main` is `"@tanstack/react-start/server-entry"`- [ ] `nodejs_compat` in `compatibility_flags`- [ ] `.dev.vars` is in `.gitignore`- [ ] No hardcoded secrets in source **Set production secrets:** ```bashopenssl rand -hex 32 | npx wrangler secret put BETTER_AUTH_SECRETecho "https://PROJECT.SUBDOMAIN.workers.dev" | npx wrangler secret put BETTER_AUTH_URLecho "http://localhost:3000,https://PROJECT.SUBDOMAIN.workers.dev" | npx wrangler secret put TRUSTED_ORIGINS``` If using Google OAuth:```bashecho "your-client-id" | npx wrangler secret put GOOGLE_CLIENT_IDecho "your-client-secret" | npx wrangler secret put GOOGLE_CLIENT_SECRET``` Add production redirect URI in Google Cloud Console: `https://PROJECT.SUBDOMAIN.workers.dev/api/auth/callback/google` **Migrate and deploy:** ```bashpnpm db:migrate:remotepnpm build && npx wrangler deploy``` After first deploy: update `BETTER_AUTH_URL` with actual Worker URL, then redeploy. **Post-deploy verification:**- [ ] App loads at production URL- [ ] Auth login/register works- [ ] CRUD operations work- [ ] Theme persists across page loads **Custom domain** (optional): Add in Cloudflare Dashboard -> Workers -> Triggers -> Custom Domains. Update `BETTER_AUTH_URL` and `TRUSTED_ORIGINS` secrets with the custom domain. Update Google OAuth redirect URI. 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 |