Joelclaw tanstack-start
Build full-stack React apps with TanStack Start — server functions, type-safe routing, loaders, middleware, SSR/streaming, and deployment patterns. Use when working on TanStack Start apps, server functions, TanStack Router, or any gremlin-cms development.
git clone https://github.com/joelhooks/joelclaw
T=$(mktemp -d) && git clone --depth=1 https://github.com/joelhooks/joelclaw "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/tanstack-start" ~/.claude/skills/joelhooks-joelclaw-tanstack-start && rm -rf "$T"
skills/tanstack-start/SKILL.mdTanStack Start
Full-stack React framework built on TanStack Router + Vite. Client-first with opt-in server capabilities. Type-safe from routes to server functions.
When to Use
Triggers:
tanstack start, tanstack router, server function, createServerFn, createFileRoute, gremlin-cms, tanstack app, or any work in a TanStack Start project.
Core Concepts
Execution Model — Critical
Route loaders are ISOMORPHIC — they run on BOTH server and client. This is the #1 gotcha.
// ❌ WRONG — loader runs on client too, exposes secrets export const Route = createFileRoute('/users')({ loader: () => { const secret = process.env.SECRET // Exposed to client! return fetch(`/api/users?key=${secret}`) }, }) // ✅ CORRECT — server function wraps server-only logic const getUsers = createServerFn().handler(() => { const secret = process.env.SECRET // Server-only return fetch(`/api/users?key=${secret}`) }) export const Route = createFileRoute('/users')({ loader: () => getUsers(), // Isomorphic call to server function })
Server Functions
Type-safe RPC that replaces REST/tRPC/GraphQL for internal data access. Build process replaces server implementations with RPC stubs in client bundles.
import { createServerFn } from '@tanstack/react-start' // GET (default) export const getPosts = createServerFn().handler(async () => { return db.posts.findMany() }) // POST with input validation export const createPost = createServerFn({ method: 'POST' }) .inputValidator((data: { title: string; body: string }) => data) .handler(async ({ data }) => { return db.posts.create(data) })
Where to call server functions:
- Route loaders — data fetching
- Components — via
hookuseServerFn() - Other server functions — compose server logic
- Event handlers — form submissions, clicks
Server-Only Functions
For utilities that must NEVER reach the client bundle:
import { createServerOnlyFn } from '@tanstack/react-start' const getDbUrl = createServerOnlyFn(() => process.env.DATABASE_URL) // Calling from client THROWS — crashes intentionally
File-Based Routing
app/ ├── routes/ │ ├── __root.tsx # Root layout │ ├── index.tsx # / │ ├── about.tsx # /about │ ├── posts/ │ │ ├── index.tsx # /posts │ │ └── $postId.tsx # /posts/:postId │ └── _authed/ │ └── dashboard.tsx # /dashboard (with auth layout) ├── client.tsx # Client entry ├── router.tsx # Router config └── ssr.tsx # SSR entry
= dynamic segment$param
= pathless layout route (groups routes without adding URL segments)_prefix
= root layout (wraps everything)__root.tsx
Route Definition
import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/posts/$postId')({ // Loader runs before render (isomorphic!) loader: ({ params }) => getPost({ data: params.postId }), // Component receives loader data component: PostPage, // Error boundary errorComponent: ({ error }) => <div>Error: {error.message}</div>, // Pending component (while loader runs) pendingComponent: () => <div>Loading...</div>, }) function PostPage() { const post = Route.useLoaderData() return <h1>{post.title}</h1> }
Middleware
Compose reusable server function middleware:
import { createMiddleware } from '@tanstack/react-start' const authMiddleware = createMiddleware({ type: 'function' }).server( async ({ next }) => { const session = await getSessionFn() if (!session?.user) throw new Error('Unauthorized') return next({ context: { session } }) } ) // Use in server functions export const listPosts = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .handler(async ({ context }) => { return db.posts.where({ userId: context.session.user.id }) })
Server Routes (API endpoints)
export const Route = createFileRoute('/api/health')({ server: { handlers: ({ createHandlers }) => createHandlers({ GET: async ({ request }) => { return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' }, }) }, }), }, })
Using with TanStack Query
import { useServerFn } from '@tanstack/react-start' import { useQuery, useMutation } from '@tanstack/react-query' function PostList() { const getPostsFn = useServerFn(getPosts) const createPostFn = useServerFn(createPost) const { data } = useQuery({ queryKey: ['posts'], queryFn: () => getPostsFn(), }) const mutation = useMutation({ mutationFn: (data) => createPostFn({ data }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }), }) }
File Organization (Large Apps)
src/utils/ ├── users.functions.ts # createServerFn wrappers (safe to import anywhere) ├── users.server.ts # Server-only helpers (DB queries, internal logic) └── schemas.ts # Shared validation schemas (client-safe)
— server function wrappers, safe to import anywhere.functions.ts
— server-only helpers, NEVER import from client code.server.ts
App Config
// app.config.ts import { defineConfig } from '@tanstack/react-start/config' import tsConfigPaths from 'vite-tsconfig-paths' export default defineConfig({ vite: { plugins: [tsConfigPaths({ projects: ['./tsconfig.json'] })], }, })
BetterAuth Integration
// Server function for session import { createServerFn } from '@tanstack/react-start' import { getRequestHeaders } from '@tanstack/react-start/server' import { auth } from '@/lib/auth' export const getSessionFn = createServerFn({ method: 'GET' }).handler( async () => { const headers = getRequestHeaders() return auth.api.getSession({ headers }) } ) // Protected layout route export const Route = createFileRoute('/_authed')({ beforeLoad: async () => { const session = await getSessionFn() if (!session?.user) throw redirect({ to: '/sign-in' }) }, component: () => <Outlet />, })
Deployment
TanStack Start deploys to any Node/Bun target, Vercel, Cloudflare Workers, Netlify.
Vercel (Critical)
You MUST add the
Vite plugin — without it, Vercel builds succeed but serve 404s.nitro()
// vite.config.ts import { nitro } from 'nitro/vite' export default defineConfig({ plugins: [ tanstackStart(), nitro(), // ← REQUIRED for Vercel viteReact(), ], })
Install:
pnpm add nitro
Monorepo (pnpm + Turborepo) quirks:
- Set
in Vercel project settings to the app dir (e.g.,rootDirectory
)apps/gremlin-cms - Framework preset should be TanStack Start or auto-detect — never Next.js
- Do NOT manually set
— Nitro generatesoutputDirectory
automatically.vercel/output - Build command from repo root:
or let Vercel auto-detectturbo run build --filter=<app-name> - If you get 404 after successful build, check: (1) nitro plugin present, (2) framework preset correct, (3) no stale
config.vercel
No CLI deploys — push to git, let Vercel auto-deploy. Only use
vercel --prod for emergency hotfixes.
Rules
- Never access
in loaders directly — useprocess.env
orcreateServerFncreateServerOnlyFn - Loaders are isomorphic — they run on both server AND client during navigation
- Server functions are the boundary — anything that touches DB, env vars, or secrets goes through
createServerFn - Prefer server functions over API routes for internal data access — type-safe, no manual fetch
- Use
hook when calling server functions from components (not direct calls)useServerFn() - Middleware composes — stack auth, validation, logging as reusable middleware
Living Document
This skill will grow as we build gremlin-cms. Update with patterns discovered during development.