mcpField
T
tanstack-start

tanstack-start

github.com/joelhooksDevelopment6537 installsUpdated 2d ago
Repository

About tanstack-start

tanstack-start is a development skill for TanStack Start projects, covering routing, server functions, and deployment patterns. It highlights that route loaders run on both server and client, and shows how to keep sensitive logic behind type-safe server boundaries.

Key features

  1. 01File-based routing and typed route definitions
  2. 02Type-safe server functions and middleware
  3. 03Distinguishing isomorphic and server-only code
  4. 04API routes and TanStack Query usage
  5. 05Deployment setup and troubleshooting

Use cases

  1. 01Building routes for a TanStack Start app
  2. 02Handling database and environment access safely
  3. 03Adding authentication middleware to server functions
  4. 04Diagnosing 404s after Vercel deployment

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/joelhooks/joelclaw --skill tanstack-start

After installing, type/tanstack-startin Claude Code to confirm it loaded.

Details

From the project README

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'
import { z } from 'zod'

// GET (default)
export const getPosts = createServerFn().handler(async () => {
  return db.posts.findMany()
})

// POST with input validation
export const createPost = createServerFn({ method: 'POST' })
  .inputValidator(z.object({ title: z.string().min(1), body: z.string() }))
  .handler(async ({ data }) => {
    return db.posts.create(data)
  })

The schema validates runtime input; a TypeScript annotation with an identity function does not. Match the validator API to the project’s pinned TanStack version.

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 rootDirectory in 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/output automatically
  • 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 .vercel config

No CLI deploys — push to git, let Vercel auto-deploy. Only use vercel --prod for emergency hotfixes.

Rules

  1. Never access process.env in loaders directly — use createServerFn or createServerOnlyFn
  2. Loaders are isomorphic — they run on both server AND client during navigation
  3. Server functions are the boundary — anything that touches DB, env vars, or secrets goes through createServerFn
  4. Prefer server functions over API routes for internal data access — type-safe, no manual fetch
  5. Use useServerFn() hook when calling server functions from components (not direct calls)
  6. 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.

Comments

Markdown supported