Claude-skills tanstack-start

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.

install
source · Clone the upstream repo
git clone https://github.com/jezweb/claude-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jezweb/claude-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/cloudflare/skills/tanstack-start" ~/.claude/skills/jezweb-claude-skills-tanstack-start && rm -rf "$T"
manifest: plugins/cloudflare/skills/tanstack-start/SKILL.md
source content

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

LayerTechnology
FrameworkTanStack Start v1 (SSR, file-based routing, server functions)
FrontendReact 19, Tailwind v4, shadcn/ui
BackendServer functions (via Nitro on Cloudflare Workers)
DatabaseD1 + Drizzle ORM
Authbetter-auth (Google OAuth + email/password)
DeploymentCloudflare 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:

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

{
  "@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:

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

RequiredOptional
Project name (kebab-case)Google OAuth credentials
One-line descriptionCustom domain
Cloudflare accountR2 storage needed?
Auth method: Google OAuth, email/password, or bothAdmin 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:

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
:

{
  "$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
:

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

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:

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
:

import { defineConfig } from "drizzle-kit";

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

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:

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:

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

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

src/routes/api/auth/$.ts
— API catch-all 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: Server Functions

Core pattern — always create DB client inside the handler:

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:

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:

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
:

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
:

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:

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
— 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
:

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
— standard TanStack Start entry point boilerplate.

Install shadcn/ui (configure to use

src/components
):

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). 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

FunctionMethodPurpose
getItems
GETList all items for current user
getItem
GETGet single item by ID
createItem
POSTCreate new item
updateItem
POSTUpdate existing item
deleteItem
POSTDelete 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

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:

  • 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:

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

Add production redirect URI in Google Cloud Console:

https://PROJECT.SUBDOMAIN.workers.dev/api/auth/callback/google

Migrate and deploy:

pnpm db:migrate:remote
pnpm 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

SymptomCauseFix
env
is undefined
Accessed at module levelUse
import { env } from "cloudflare:workers"
inside request handler only
D1 database not foundBinding mismatchCheck
d1_databases
binding name in wrangler.jsonc matches code
Auth redirect loopURL mismatch
BETTER_AUTH_URL
must match actual URL exactly (protocol + domain, no trailing slash)
Auth silently failsMissing originsSet
TRUSTED_ORIGINS
secret with all valid URLs (comma-separated)
Styles not loadingMissing pluginEnsure
@tailwindcss/vite
plugin is in vite.config.ts
SSR hydration mismatchTheme flashAdd
suppressHydrationWarning
to
<html>
element
Build fails on CloudflareBad configCheck
nodejs_compat
flag and
main
field in wrangler.jsonc
Secrets not taking effectNo redeploy
wrangler secret put
does NOT redeploy — run
npx wrangler deploy
after
Auth endpoints return 404Wrong route typeUse
createAPIFileRoute
(API route), not
createServerFn
for better-auth
"redirect_uri_mismatch"Missing URIAdd production URL to Google Cloud Console OAuth redirect URIs
Cryptic Vite errorsPlugin orderMust be:
cloudflare()
->
tailwindcss()
->
tanstackStart()
->
viteReact()
"Table not found" 500sMissing migrationRun
pnpm db:migrate:remote
before deploying