tanstack-start
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:
package.json— all runtime + dev dependencies with version ranges from architecture.mdtsconfig.json— strict mode,@/*path alias mapped tosrc/*vite.config.ts— plugins in correct order:cloudflare()→tailwindcss()→tanstackStart()→viteReact()wrangler.jsonc—main: "@tanstack/react-start/server-entry",nodejs_compatflag, D1 binding placeholder.dev.vars— generateBETTER_AUTH_SECRETwithopenssl rand -hex 32, setBETTER_AUTH_URL=http://localhost:3000,TRUSTED_ORIGINS=http://localhost:3000.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
integerfor timestamps (Unix epoch), NOT Date objects - Use
textfor 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
suppressHydrationWarningon<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
:rootand.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 buttonuser-nav.tsx— user dropdown menu with sign-out actionstat-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:
- Gets auth session (redirect if not authenticated)
- Creates per-request Drizzle client via
getDb() - Performs the database operation
- 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 |