Agent skill
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.
Install this agent skill to your Project
npx add-skill https://github.com/joelhooks/joelclaw/tree/main/skills/tanstack-start
SKILL.md
TanStack 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
useServerFn()hook - 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
$param= dynamic segment_prefix= pathless layout route (groups routes without adding URL segments)__root.tsx= root layout (wraps everything)
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)
.functions.ts— server function wrappers, safe to import anywhere.server.ts— server-only helpers, NEVER import from client code
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 nitro() Vite plugin — without it, Vercel builds succeed but serve 404s.
// 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
rootDirectoryin Vercel project settings to the app dir (e.g.,apps/gremlin-cms) - Framework preset should be TanStack Start or auto-detect — never Next.js
- Do NOT manually set
outputDirectory— Nitro generates.vercel/outputautomatically - Build command from repo root:
turbo run build --filter=<app-name>or let Vercel auto-detect - If you get 404 after successful build, check: (1) nitro plugin present, (2) framework preset correct, (3) no stale
.vercelconfig
No CLI deploys — push to git, let Vercel auto-deploy. Only use vercel --prod for emergency hotfixes.
Rules
- Never access
process.envin loaders directly — usecreateServerFnorcreateServerOnlyFn - 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
useServerFn()hook when calling server functions from components (not direct calls) - 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.
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
skill-review
Audit and maintain the joelclaw skill inventory. Use when checking skill health, fixing broken symlinks, finding stale skills, or running the skill garden. Triggers: 'skill audit', 'check skills', 'stale skills', 'skill health', 'skill garden', 'broken skill', 'skill review', 'fix skills', 'garden skills', or any task involving skill inventory maintenance.
cli-design
Design and build agent-first CLIs with HATEOAS JSON responses, context-protecting output, and self-documenting command trees. Use when creating new CLI tools, adding commands to existing CLIs (joelclaw, slog), or reviewing CLI design for agent-friendliness. Triggers on 'build a CLI', 'add a command', 'CLI design', 'agent-friendly output', or any task involving command-line tool creation.
daily-summary
Generate a daily system activity summary across all joelclaw subsystems. Triggers on 'daily summary', 'what happened today', 'system report', 'today's tally', 'activity summary', 'what did we do', 'daily report'.
inngest-durable-functions
Create and configure Inngest durable functions. Covers triggers (events, cron, invoke), step execution and memoization, idempotency, cancellation, error handling, retries, logging, and observability.
recall
Fan-out search across all memory sources when context is unclear or vaguely referenced. Triggers on: 'from earlier', 'remember when', 'what we discussed', 'that thing with', 'the conversation about', 'did we ever', 'what happened with', 'you mentioned', 'we talked about', 'earlier today', 'last session', 'the other day', or any vague reference to past context that needs resolution before the agent can act.
inngest-local
Set up self-hosted Inngest on macOS as a durable background task manager for AI agents. Interactive Q&A to match intent — from Docker one-liner to full k8s deployment with persistent state. Use when: 'set up inngest', 'background tasks', 'durable workflows', 'self-host inngest', 'event-driven functions', 'cron jobs', or any request for a local workflow engine.
Didn't find tool you were looking for?