tanstack-start
從零產生 Cloudflare Workers 上的全端 TanStack Start app
中文翻譯
---
name: tanstack-start
description: "從零開始在 Cloudflare Workers 上建一個全端 TanStack Start app:SSR、file-based routing、server function、D1+Drizzle、better-auth、Tailwind v4+shadcn/ui。只要使用者提到 TanStack Start、要用 SSR 做全端 Cloudflare app 的腳手架、想要 SSR 儀表板,或要一個 React 19 + Cloudflare Workers、有 file-based routing 和 server function 的 app,就用這個 skill,即使他沒有明講 TanStack Start。沒有 template repo,Claude 會針對每個專案重新產生每一個檔案。"
compatibility: claude-code-only
---
# TanStack Start on Cloudflare
從零建出一個完整的全端 app。每個檔案都由 Claude 產生,不 clone template,也不用 scaffold 指令。
技術堆疊:TanStack Start v1(SSR、file-based routing、透過 Nitro 的 server function)跑在 Cloudflare Workers 上;React 19 + Tailwind v4 + shadcn/ui;D1 + Drizzle;better-auth(Google OAuth + email/password)。
## 專案檔案樹
```
PROJECT_NAME/
├── src/
│ ├── routes/
│ │ ├── __root.tsx # 根 layout(HTML 外殼、主題、CSS import)
│ │ ├── index.tsx # 首頁 / 驗證導向
│ │ ├── login.tsx # 登入頁
│ │ ├── register.tsx # 註冊頁
│ │ ├── _authed.tsx # 驗證守衛 layout route
│ │ ├── _authed/
│ │ │ ├── dashboard.tsx # 有統計卡片的儀表板
│ │ │ ├── items.tsx # Item 列表表格
│ │ │ ├── items.$id.tsx # 編輯 item
│ │ │ └── items.new.tsx # 建立 item
│ │ └── api/
│ │ └── auth/
│ │ └── $.ts # better-auth API catch-all
│ ├── components/
│ │ ├── ui/ # shadcn/ui 元件(自動安裝)
│ │ ├── app-sidebar.tsx # 導覽側邊欄
│ │ ├── theme-toggle.tsx # Light/dark/system 切換
│ │ ├── user-nav.tsx # 使用者下拉選單
│ │ └── stat-card.tsx # 儀表板統計卡片
│ ├── db/
│ │ ├── schema.ts # Drizzle schema(所有資料表)
│ │ └── index.ts # Drizzle client factory
│ ├── lib/
│ │ ├── auth.server.ts # better-auth server 設定
│ │ ├── auth.client.ts # better-auth React hook
│ │ └── utils.ts # shadcn/ui 用的 cn() helper
│ ├── server/
│ │ └── functions.ts # Server function(CRUD、驗證檢查)
│ ├── styles/
│ │ └── app.css # Tailwind v4 + shadcn/ui CSS 變數
│ ├── router.tsx # TanStack Router 設定
│ ├── client.tsx # Client 進入點(hydrateRoot)
│ ├── ssr.tsx # SSR 進入點
│ └── routeTree.gen.ts # 自動產生的 route tree(不要編輯)
├── drizzle/ # 產生出來的 migration
├── public/ # 靜態資產(favicon 等)
├── vite.config.ts
├── wrangler.jsonc
├── drizzle.config.ts
├── tsconfig.json
├── package.json
├── .dev.vars # 本機環境變數(不要 commit)
└── .gitignore
```
## 相依套件
**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"
}
```
## 工作流程
### 步驟 1:收集專案資訊
| 必填 | 選填 |
|----------|----------|
| 專案名稱(kebab-case) | Google OAuth 憑證 |
| 一句話描述 | 自訂網域 |
| Cloudflare 帳號 | 需要 R2 儲存嗎? |
| 驗證方式:Google OAuth、email/password,或兩者都要 | 管理員 email |
### 步驟 2:初始化專案
建立專案目錄,並從零建立所有設定檔。
**`vite.config.ts`**:plugin 順序很重要,Cloudflare 一定要放第一個:
```typescript
import { 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"
}
]
}
```
重點:`main` 一定要是 `"@tanstack/react-start/server-entry"`(Nitro server 進入點)。用 `nodejs_compat`(不是 `node_compat`)。加上 `account_id` 可以避免互動式提問。
**`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`**:用 `openssl rand -hex 32` 產生 `BETTER_AUTH_SECRET`:
```
BETTER_AUTH_SECRET=<generated-hex-32>
BETTER_AUTH_URL=http://localhost:3000
TRUSTED_ORIGINS=http://localhost:3000
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
```
**`.gitignore`**:node_modules、.wrangler、dist、.output、.dev.vars、.vinxi、.DS_Store
接著安裝並建立 D1 資料庫:
```bash
cd PROJECT_NAME && pnpm install
npx wrangler d1 create PROJECT_NAME-db
# Copy the database_id into wrangler.jsonc d1_databases binding
```
### 步驟 3:資料庫 Schema
**`src/db/schema.ts`**:放所有資料表。better-auth 需要:`users`、`sessions`、`accounts`、`verifications`。再加上應用程式的資料表(例如 `items`)做 CRUD 示範。
D1 專屬規則:
- timestamp 用 `integer`(Unix epoch),不要用 Date 物件
- 主鍵用 `text`(nanoid/cuid2),不要用 autoincrement
- 每個查詢的 bound parameter 控制在 100 個以內(大量 insert 要分批)
- 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 });
}
```
**重要**:用 `import { env } from "cloudflare:workers"`,不要用 `process.env`。Drizzle client 要在每個 server function 裡面建立(每個 request 一個),不要在 module 層級建。
**`drizzle.config.ts`**:
```typescript
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
});
```
產生並套用初始 migration:
```bash
pnpm db:generate
pnpm db:migrate:local
```
### 步驟 4:設定驗證
**`src/lib/auth.server.ts`**:Server 端的 better-auth:
```typescript
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
},
});
}
```
**重要**:`getAuth()` 必須在每個 request 內呼叫(在 handler/loader 裡面),不要在 module 層級呼叫。
**`src/lib/auth.client.ts`**:Client 端的驗證 hook:
```typescript
import { createAuthClient } from "better-auth/react";
export const { useSession, signIn, signOut, signUp } = createAuthClient();
```
**`src/routes/api/auth/$.ts`**:better-auth 的 API catch-all:
```typescript
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),
});
```
**重要**:驗證一定要用 API route(`createAPIFileRoute`),不要用 server function(`createServerFn`)。better-auth 需要直接存取 request/response。
### 步驟 5:Server Function
**核心模式**:一定要在 handler 裡面建立 DB client:
```typescript
import { 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();
});
```
**用 Zod 做輸入驗證**:
```typescript
export 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 };
});
```
**受保護的 server function**:檢查驗證,沒驗證就 throw redirect:
```typescript
import { 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 模式**:在 route 的 `loader` 裡用 server function:
```typescript
export 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>;
}
```
**驗證守衛**(`_authed.tsx`):用 `beforeLoad`:
```typescript
export const Route = createFileRoute("/_authed")({
beforeLoad: async () => {
const session = await getSessionFn();
if (!session) {
throw redirect({ to: "/login" });
}
return { session };
},
});
```
子 route 透過 `Route.useRouteContext()` 取得 session。
**Mutation + invalidation**:mutation 之後要 invalidate router,讓 loader 重新抓資料:
```typescript
function CreateItemForm() {
const router = useRouter();
const handleSubmit = async (data: NewItem) => {
await createItem({ data });
router.invalidate();
router.navigate({ to: "/items" });
};
return <form onSubmit={...}>...</form>;
}
```
**型別安全**:server function 的輸入/輸出型別用 Drizzle 的 `InferSelectModel` / `InferInsertModel`。驗證失敗一律用 `throw redirect()`,不要回傳錯誤 response。
### 步驟 6:App 外殼 + 主題
**`src/routes/__root.tsx`**:完整的 HTML 文件,含 `@tanstack/react-router` 的 `<HeadContent />` + `<Scripts />`,`<html>` 上加 `suppressHydrationWarning`(SSR + 主題),inline 主題初始化 script 以避免閃爍,以及全域 CSS import。
**`src/styles/app.css`**:`@import "tailwindcss"`(v4 語法)+ 放在 `:root` 和 `.dark` 的 shadcn/ui CSS 變數。只用語意化 token。
**`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`**:標準的 TanStack Start 進入點樣板程式碼。
安裝 shadcn/ui:
```bash
pnpm dlx shadcn@latest init --defaults
pnpm dlx shadcn@latest add button card input label sidebar table dropdown-menu form separator sheet
```
**主題切換**:三種狀態(light → dark → system → light),存在 localStorage,`.dark` class 加在 `<html>` 上。系統偏好**只用 JS** 偵測,不要用 CSS `@media (prefers-color-scheme)` 查詢。
**元件**放在 `src/components/`:`app-sidebar.tsx`、`theme-toggle.tsx`、`user-nav.tsx`、`stat-card.tsx`。
### 步驟 7:CRUD Server Function
| 函式 | Method | 用途 |
|----------|--------|---------|
| `getItems` | GET | 列出目前使用者的所有 item |
| `getItem` | GET | 依 ID 取得單一 item |
| `createItem` | POST | 建立新 item |
| `updateItem` | POST | 更新既有 item |
| `deleteItem` | POST | 依 ID 刪除 item |
每個 server function:(1) 取得驗證 session,(2) 透過 `getDb()` 建立每個 request 專屬的 Drizzle client,(3) 執行 DB 操作,(4) 回傳有型別的資料。Route loader 呼叫 GET 函式。Mutation 呼叫 POST 函式,然後 `router.invalidate()`。
### 步驟 8:本機驗證
```bash
pnpm dev
```
- [ ] App 在 http://localhost:3000 載入成功
- [ ] 註冊新帳號(email/password)
- [ ] 登入和登出都正常
- [ ] 儀表板載入,統計卡片有顯示
- [ ] 建立、列出、編輯、刪除 item
- [ ] 主題切換會依序循環:light -> dark -> system
- [ ] 側邊欄在行動裝置上會收合
- [ ] Console 沒有錯誤
### 步驟 9:部署到正式環境
**部署前檢查清單**:執行 deploy 之前先確認:
- [ ] `wrangler.jsonc` 有正確的 `account_id`;`main` 是 `"@tanstack/react-start/server-entry"`;`compatibility_flags` 裡有 `nodejs_compat`
- [ ] D1 資料庫已建立,`database_id` 已填入
- [ ] `.dev.vars` 已加入 gitignore;原始碼裡沒有寫死的 secret
**設定正式環境的 secret:**
```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
```
如果用 Google OAuth,要在 Google Cloud Console 加上正式環境的 redirect URI:`https://PROJECT.SUBDOMAIN.workers.dev/api/auth/callback/google`。
**Migrate 並部署:**
```bash
pnpm db:migrate:remote
pnpm build && npx wrangler deploy
```
第一次部署之後,把 `BETTER_AUTH_URL` 更新成實際的 Worker URL,再重新部署一次。
**驗證:** app 在正式網址載入成功、驗證正常、CRUD 正常、主題有保留。
**自訂網域**(選用):Cloudflare Dashboard → Workers → Triggers → Custom Domains。把 `BETTER_AUTH_URL` + `TRUSTED_ORIGINS` secret 和 Google OAuth redirect URI 都更新成新網域,然後重新部署。
## 常見問題
| 症狀 | 原因 | 解法 |
|---------|-------|-----|
| `env` 是 undefined | 在 module 層級存取 | 只在 request handler 裡使用 `import { env } from "cloudflare:workers"` |
| 找不到 D1 資料庫 | Binding 對不上 | 檢查 wrangler.jsonc 裡 `d1_databases` 的 binding 名稱跟程式碼一致 |
| 驗證 redirect 無限迴圈 | URL 不一致 | `BETTER_AUTH_URL` 必須跟實際網址完全一致(協定 + 網域,結尾不要有斜線) |
| 驗證無聲失敗 | 缺少 origin | 用 `TRUSTED_ORIGINS` secret 設定所有合法網址(以逗號分隔) |
| 樣式沒載入 | 缺少 plugin | 確認 vite.config.ts 裡有 `@tailwindcss/vite` plugin |
| SSR hydration 不一致 | 主題閃爍 | 在 `<html>` 元素加上 `suppressHydrationWarning` |
| 在 Cloudflare 上 build 失敗 | 設定錯誤 | 檢查 wrangler.jsonc 裡的 `nodejs_compat` flag 和 `main` 欄位 |
| Secret 沒有生效 | 沒有重新部署 | `wrangler secret put` **不會**重新部署,之後要跑 `npx wrangler deploy` |
| 驗證 endpoint 回傳 404 | Route 類型錯誤 | better-auth 要用 `createAPIFileRoute`(API route),不要用 `createServerFn` |
| "redirect_uri_mismatch" | 缺少 URI | 把正式環境網址加到 Google Cloud Console 的 OAuth redirect URI |
| 看不懂的 Vite 錯誤 | Plugin 順序 | 一定要是:`cloudflare()` -> `tailwindcss()` -> `tanstackStart()` -> `viteReact()` |
| "Table not found" 500 錯誤 | 缺少 migration | 部署之前先跑 `pnpm db:migrate:remote` |英文原文
---
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
│ │ │ ├── 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:
```typescript
import { 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:3000
TRUSTED_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:
```bash
cd PROJECT_NAME && pnpm install
npx 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:
```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.
**`drizzle.config.ts`**:
```typescript
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
});
```
Generate and apply the initial migration:
```bash
pnpm db:generate
pnpm db:migrate:local
```
### Step 4: Configure Auth
**`src/lib/auth.server.ts`** — Server-side better-auth:
```typescript
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.
**`src/lib/auth.client.ts`** — Client-side auth hooks:
```typescript
import { createAuthClient } from "better-auth/react";
export const { useSession, signIn, signOut, signUp } = createAuthClient();
```
**`src/routes/api/auth/$.ts`** — API catch-all for better-auth:
```typescript
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: Server Functions
**Core pattern** — always create DB client inside the handler:
```typescript
import { 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:
```typescript
export 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:
```typescript
import { 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`:
```typescript
export 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`:
```typescript
export 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:
```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.
Install shadcn/ui:
```bash
pnpm dlx shadcn@latest init --defaults
pnpm dlx shadcn@latest add button card input label sidebar table dropdown-menu form separator sheet
```
**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 |