skills/jezweb/claude-skills/tanstack-start

tanstack-start

SKILL.md

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

Workflow

Step 1: Gather Project Info

Ask for:

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.

See references/architecture.md for the complete file tree, all dependencies, and config templates.

Create these files first:

  1. package.json — all runtime + dev dependencies with version ranges from architecture.md
  2. tsconfig.json — strict mode, @/* path alias mapped to src/*
  3. vite.config.ts — plugins in correct order: cloudflare()tailwindcss()tanstackStart()viteReact()
  4. wrangler.jsoncmain: "@tanstack/react-start/server-entry", nodejs_compat flag, D1 binding placeholder
  5. .dev.vars — generate BETTER_AUTH_SECRET with openssl rand -hex 32, set BETTER_AUTH_URL=http://localhost:3000, TRUSTED_ORIGINS=http://localhost:3000
  6. .gitignore — node_modules, .wrangler, dist, .output, .dev.vars, .vinxi, .DS_Store

Then:

cd PROJECT_NAME
pnpm install

Create D1 database and update wrangler.jsonc:

npx wrangler d1 create PROJECT_NAME-db
# Copy the database_id into wrangler.jsonc d1_databases binding

Step 3: Database Schema

Create the Drizzle schema with D1-correct patterns.

src/db/schema.ts — Define all tables:

  • better-auth tables: users, sessions, accounts, verifications — these are required by better-auth
  • Application table: items (or whatever the project needs) 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:

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. This is a per-request binding, so create the Drizzle client inside each server function, not at module level.

drizzle.config.ts:

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "sqlite",
});

Add migration scripts to package.json:

{
  "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"
}

Generate and apply the initial migration:

pnpm db:generate
pnpm db:migrate:local

Step 4: Configure Auth

src/lib/auth.server.ts — Server-side better-auth configuration:

import { 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. The env import from cloudflare:workers is only available during request handling.

src/lib/auth.client.ts — Client-side auth hooks:

import { createAuthClient } from "better-auth/react";

export const { useSession, signIn, signOut, signUp } = createAuthClient();

src/routes/api/auth/$.ts — API catch-all route for better-auth:

import { 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: App Shell + Theme

src/routes/__root.tsx — Root layout with HTML document:

  • Render full HTML document with <HeadContent /> and <Scripts /> from @tanstack/react-router
  • Add suppressHydrationWarning on <html> for SSR + theme toggle compatibility
  • Import the global CSS file
  • Include theme initialisation script inline to prevent flash of wrong theme

src/styles/app.css — Tailwind v4 + shadcn/ui:

  • @import "tailwindcss" (v4 syntax)
  • CSS variables for shadcn/ui tokens in :root and .dark
  • Neutral/monochrome palette (stone, slate, zinc)
  • Use semantic tokens only — never raw Tailwind colours

src/router.tsx — Router configuration:

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 and src/ssr.tsx — Entry points (standard TanStack Start boilerplate).

Install shadcn/ui components needed for the dashboard:

pnpm dlx shadcn@latest init --defaults
pnpm dlx shadcn@latest add button card input label sidebar table dropdown-menu form separator sheet

Note: Configure shadcn to use src/components as the components directory.

Theme toggle: three-state (light → dark → system → light). Store preference in localStorage. Apply .dark class on <html>. Use JS-only system preference detection — NO CSS @media (prefers-color-scheme) queries.

Step 6: Routes + Dashboard

Create the route files:

src/routes/
├── __root.tsx           # Root layout (HTML shell, theme, CSS)
├── index.tsx            # Landing → redirect to /dashboard if authenticated
├── login.tsx            # Login form (email/password + Google OAuth button)
├── register.tsx         # Registration form
├── _authed.tsx          # Auth guard layout (beforeLoad checks session)
└── _authed/
    ├── dashboard.tsx    # Stat cards overview
    ├── items.tsx        # Items list (table with actions)
    ├── items.$id.tsx    # Edit item form
    └── items.new.tsx    # Create item form

Auth guard pattern (_authed.tsx):

import { createFileRoute, redirect } from "@tanstack/react-router";
import { getSessionFn } from "../server/auth";

export const Route = createFileRoute("/_authed")({
  beforeLoad: async () => {
    const session = await getSessionFn();
    if (!session) {
      throw redirect({ to: "/login" });
    }
    return { session };
  },
});

Components (in src/components/):

  • app-sidebar.tsx — shadcn Sidebar with navigation links (Dashboard, Items)
  • theme-toggle.tsx — three-state theme toggle button
  • user-nav.tsx — user dropdown menu with sign-out action
  • stat-card.tsx — reusable stat card for the dashboard

See references/server-functions.md for createServerFn patterns used in route loaders and mutations.

Step 7: CRUD Server Functions

Create server functions for the items resource:

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 (redirect if not authenticated)
  2. Creates per-request Drizzle client via getDb()
  3. Performs the database operation
  4. Returns typed data

Route loaders call GET server functions. Mutations call POST server functions then router.invalidate() to refetch.

Step 8: Verify Locally

pnpm dev

Verification checklist:

  • App loads at http://localhost:3000
  • Register a new account (email/password)
  • Login and logout work
  • Dashboard page loads with stat cards
  • Create a new item via /items/new
  • Items list shows the new item
  • Edit item via /items/:id
  • Delete item from the list
  • Theme toggle cycles: light → dark → system
  • Sidebar collapses on mobile viewports
  • No console errors

Step 9: Deploy to Production

# Set production secrets
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

# If using Google OAuth
echo "your-client-id" | npx wrangler secret put GOOGLE_CLIENT_ID
echo "your-client-secret" | npx wrangler secret put GOOGLE_CLIENT_SECRET

# Migrate remote database
pnpm db:migrate:remote

# Build and deploy
pnpm build && npx wrangler deploy

After first deploy: Update BETTER_AUTH_URL with your actual Worker URL. Add production URL to Google OAuth redirect URIs: https://your-app.workers.dev/api/auth/callback/google.

See references/deployment.md for the full production checklist and common mistakes.

Common Issues

Symptom Fix
env is undefined in server function Use import { env } from "cloudflare:workers" — must be inside request handler, not module scope
D1 database not found Check wrangler.jsonc d1_databases binding name matches code
Auth redirect loop BETTER_AUTH_URL must match actual URL exactly (protocol + domain, no trailing slash)
Auth silently fails (redirects to home) Set TRUSTED_ORIGINS secret with all valid URLs (comma-separated)
Styles not loading in dev Ensure @tailwindcss/vite plugin is in vite.config.ts
SSR hydration mismatch Add suppressHydrationWarning to <html> element
Build fails on Cloudflare Check nodejs_compat in compatibility_flags, main field in wrangler.jsonc
Secrets not taking effect wrangler secret put does NOT redeploy — run npx wrangler deploy after
Weekly Installs
705
GitHub Stars
602
First Seen
Jan 31, 2026
Installed on
opencode560
codex523
gemini-cli510
github-copilot491
claude-code466
cursor454