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.md
tsconfig.json
— strict mode,
@/
path alias mapped to
src/
vite.config.ts
— plugins in correct order:
cloudflare()
→
tailwindcss()
→
tanstackStart()
→
viteReact()
wrangler.jsonc
—
main: "@tanstack/react-start/server-entry"
,
nodejs_compat
flag, D1 binding placeholder
.dev.vars
— generate
BETTER_AUTH_SECRET
with
openssl rand -hex 32
, set
BETTER_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
- 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
and
from
@tanstack/react-router
Add
suppressHydrationWarning
on
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
. 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:
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
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