# Tanstack Start

> TanStack Start rules - full-stack React framework with type-safe routing, server functions, SSR/SSG, TanStack Query integration, streaming

- Skill: `14bryanespinoza/tanstack-start` (Agent Skill)
- Install (CLI): `npx skillmds@latest add 14bryanespinoza/tanstack-start`
- Raw SKILL.md: https://api.skillmd.com/api/skills/14bryanespinoza/tanstack-start/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: 14BryanEspinoza (https://skillmd.com/u/14bryanespinoza)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/14bryanespinoza/tanstack-start

---


# TanStack Start — Rules and Conventions

---

## 1. Philosophy

1. **Type-safe by default** — End-to-end type safety from routes to server functions to queries.
2. **Full-stack React** — Single framework for client + server. No API layer duplication.
3. **Server-first rendering** — SSR/SSG by default. Client hydration only when needed.
4. **TanStack ecosystem unified** — Router + Query + Start work together seamlessly.
5. **Vinxi-powered** — Built on Vinxi for server, SSR, streaming, and deployment flexibility.
6. **Standards-based** — Web standards (fetch, Request, Response, streams). No proprietary APIs.

---

## 2. Minimum Versions

| Technology      | Minimum Version   |
| --------------- | ----------------- |
| TanStack Router | 1.80+             |
| TanStack Query  | 5.50+             |
| TanStack Start  | 1.0+ (Vinxi 0.4+) |
| React           | 18.3+             |
| TypeScript      | 5.4+              |
| Node.js         | 22+               |
| Vinxi           | 0.4+              |

---

## 3. Setup & Project Structure

### Initialize

```bash
# Using create-tanstack-app (recommended)
npx create-tanstack-app@latest my-app --framework=react --template=start

# Or manual with Vinxi
pnpm add @tanstack/start @tanstack/react-router @tanstack/react-query @tanstack/router-plugin
pnpm add -D vinxi @tanstack/router-devtools
```

### Project Structure

```text
src/
├── routes/                 # File-based routes (TanStack Router)
│   ├── __root.tsx          # Root layout
│   ├── index.tsx           # Home page
│   ├── posts/
│   │   ├── index.tsx       # /posts
│   │   ├── $postId.tsx     # /posts/:postId
│   │   └── $postId.edit.tsx
│   └── api/                # Server functions (not file-based routes)
├── components/             # Shared UI components
├── lib/                    # Utilities, DB, auth
├── query-client.ts         # TanStack Query setup
├── router.tsx              # Router configuration
├── server-functions/       # Server functions
│   ├── posts.ts
│   └── auth.ts
├── styles/
│   └── globals.css
├── app.tsx                 # App entry (client)
├── server.tsx              # Server entry (Vinxi)
├── tsconfig.json
├── vinxi.config.ts
└── package.json
```

### Key Config Files

```ts
// vinxi.config.ts
import { defineConfig } from "vinxi";
import tanstackRouter from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    tanstackRouter({ target: "react", autoCodeSplitting: true }),
    react(),
  ],
  server: { preset: "node-server" },
});
```

```ts
// router.tsx
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 1000 * 60 * 5 },
  },
});

export const router = createRouter({
  routeTree,
  defaultPreload: "intent",
  scrollRestoration: true,
});

declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router;
  }
}
```

---

## 4. TanStack Router — File-Based Routing

### Route Conventions

| File                                           | Route                                | Purpose                |
| ---------------------------------------------- | ------------------------------------ | ---------------------- |
| `routes/__root.tsx`                            | `/`                                  | Root layout, providers |
| `routes/index.tsx`                             | `/`                                  | Home page              |
| `routes/posts.index.tsx`                       | `/posts`                             | Posts list             |
| `routes/posts.$postId.tsx`                     | `/posts/:postId`                     | Post detail            |
| `routes/posts.$postId.edit.tsx`                | `/posts/:postId/edit`                | Edit page              |
| `routes/posts.$postId.comments.$commentId.tsx` | `/posts/:postId/comments/:commentId` | Nested                 |

### Route with Loader (Server Data)

```tsx
// routes/posts.$postId.tsx
import { createFileRoute } from "@tanstack/react-router";
import { getPost } from "@/server-functions/posts";

export const Route = createFileRoute("/posts/$postId")({
  loader: async ({ params: { postId } }) => {
    const post = await getPost(postId);
    if (!post) throw new Response("Not found", { status: 404 });
    return { post };
  },
  component: PostDetail,
});

function PostDetail() {
  const { post } = Route.useLoaderData();
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}
```

### Search Params Validation (Zod)

```tsx
// routes/posts.index.tsx
import { z } from "zod";
import { createFileRoute } from "@tanstack/react-router";

const searchSchema = z.object({
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(50).default(10),
  search: z.string().optional(),
  sort: z.enum(["newest", "oldest", "popular"]).default("newest"),
});

export const Route = createFileRoute("/posts")({
  validateSearch: searchSchema,
  loader: async ({ search }) => {
    const posts = await fetchPosts(search);
    return { posts, meta: { total: 100 } };
  },
  component: PostsList,
});

function PostsList() {
  const { posts, meta } = Route.useLoaderData();
  const search = Route.useSearch();
  // ...
}
```

### Search Params Navigation

```tsx
function PostsList() {
  const navigate = Route.useNavigate();
  const search = Route.useSearch();

  function handlePageChange(page: number) {
    navigate({ search: { ...search, page } });
  }

  function handleSort(sort: string) {
    navigate({ search: { ...search, sort, page: 1 } });
  }

  return (
    <div>
      <select value={search.sort} onChange={(e) => handleSort(e.target.value)}>
        <option value="newest">Newest</option>
        <option value="popular">Popular</option>
      </select>
      <button onClick={() => handlePageChange(search.page + 1)}>Next</button>
    </div>
  );
}
```

### Redirects & Error Boundaries

```tsx
// Redirect in loader
loader: async ({ params }) => {
  const post = await getPost(params.postId);
  if (!post) throw redirect("/posts");
  return { post };
};

// Error boundary
errorComponent: () => <div>Failed to load</div>;

// Pending UI
pendingComponent: () => <Skeleton />;
```

---

## 5. Server Functions

### Basic Server Function

```tsx
// server-functions/posts.ts
import { createServerFn } from "@tanstack/start";
import { z } from "zod";
import { db } from "@/lib/db";

const createPostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10),
});

export const createPost = createServerFn("POST", createPostSchema)
  .middleware([authMiddleware]) // Optional auth
  .handler(async ({ data }) => {
    const post = await db.posts.create({ data });
    return post;
  });

export const getPost = createServerFn("GET")
  .validator((input: unknown) => z.object({ id: z.string() }).parse(input))
  .handler(async ({ data }) => {
    const post = await db.posts.findUnique({ where: { id: data.id } });
    if (!post) throw new Response("Not found", { status: 404 });
    return post;
  });

export const updatePost = createServerFn("PATCH")
  .validator(z.object({ id: z.string(), title: z.string().optional() }))
  .handler(async ({ data }) => {
    return db.posts.update({ where: { id: data.id }, data });
  });

export const deletePost = createServerFn("DELETE")
  .validator(z.object({ id: z.string() }))
  .handler(async ({ data }) => {
    await db.posts.delete({ where: { id: data.id } });
    return { success: true };
  });
```

### Client Usage

```tsx
// components/CreatePostForm.tsx
import { useMutation } from "@tanstack/react-query";
import { createPost } from "@/server-functions/posts";

export function CreatePostForm() {
  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        mutation.mutate(Object.fromEntries(formData));
      }}
    >
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? "Creating..." : "Create"}
      </button>
    </form>
  );
}
```

### Middleware

```tsx
// server-functions/middleware/auth.ts
import { createServerFn } from "@tanstack/start";
import { getSession } from "@/lib/auth";

export const authMiddleware = createServerFn("MIDDLEWARE").handler(
  async ({ context }) => {
    const session = await getSession(context.request.headers.get("cookie"));
    if (!session) throw new Response("Unauthorized", { status: 401 });
    return { user: session.user };
  },
);

// Usage
export const createPost = createServerFn("POST", schema)
  .middleware([authMiddleware])
  .handler(async ({ data, context }) => {
    // context.user available
    return db.posts.create({ data: { ...data, authorId: context.user.id } });
  });
```

---

## 6. TanStack Query Integration

### QueryClient Setup

```tsx
// query-client.ts
import { QueryClient } from "@tanstack/react-query";
import { createQueryClient } from "@tanstack/start";

export const queryClient = createQueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      gcTime: 1000 * 60 * 30,
      retry: 1,
    },
  },
});
```

### SSR Hydration

```tsx
// server.tsx (Vinxi entry)
import { createStartServer } from "@tanstack/start/server";
import { queryClient } from "@/query-client";
import { router } from "@/router";
import { App } from "@/app";

export default createStartServer({
  router,
  queryClient,
  App,
});
```

```tsx
// app.tsx (Client entry)
import { HydrationBoundary } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { router, queryClient } from "@/router";

export function App() {
  return (
    <HydrationBoundary
      state={window.__TS_START_QUERY_STATE__}
      client={queryClient}
    >
      <RouterProvider router={router} />
    </HydrationBoundary>
  );
}
```

### Prefetching in Loaders

```tsx
// routes/posts.$postId.tsx
import { queryClient } from "@/query-client";

export const Route = createFileRoute("/posts/$postId")({
  loader: async ({ params: { postId } }) => {
    // Prefetch for instant client navigation
    await queryClient.prefetchQuery({
      queryKey: ["post", postId],
      queryFn: () => fetchPost(postId),
    });
    const post = await queryClient.getQueryData(["post", postId]);
    return { post };
  },
});
```

### Optimistic Updates

```tsx
// server-functions/posts.ts
export const updatePost = createServerFn("PATCH", schema).handler(
  async ({ data }) => {
    return db.posts.update({ where: { id: data.id }, data });
  },
);

// Client
function PostEditor({ postId }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: updatePost,
    onMutate: async (newData) => {
      await queryClient.cancelQueries({ queryKey: ["post", postId] });
      const previous = queryClient.getQueryData(["post", postId]);
      queryClient.setQueryData(["post", postId], (old) => ({
        ...old,
        ...newData,
      }));
      return { previous };
    },
    onError: (err, newData, context) => {
      queryClient.setQueryData(["post", postId], context.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["post", postId] });
    },
  });
}
```

---

## 7. TanStack Start Features

### SSR/SSG/SPA Modes

```tsx
// vinxi.config.ts
export default defineConfig({
  // SSR (default)
  // server: { preset: 'node-server' },
  // SSG (static export)
  // build: { preset: 'static' },
  // SPA only
  // client: { only: true }
});
```

### Route-Level Mode Override

```tsx
// routes/posts.$postId.tsx
export const Route = createFileRoute("/posts/$postId")({
  // Force static generation
  // staticGenerate: true,
  // Or force SSR
  // prerender: false,
});
```

### Streaming

```tsx
// routes/dashboard.tsx
import { Suspense } from "react";

export const Route = createFileRoute("/dashboard")({
  component: Dashboard,
});

function Dashboard() {
  return (
    <section>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsCards />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
    </section>
  );
}
```

---

## 8. Styling

### Tailwind CSS (Recommended)

```bash
pnpm add -D tailwindcss postcss autoprefixer
pnpm tailwindcss init -p
```

```css
/* styles/globals.css */
@import "tailwindcss";

@theme {
  --color-primary: #0066cc;
  --color-secondary: #6c757d;
}
```

```tsx
// app.tsx or __root.tsx
import "./styles/globals.css";
```

### CSS Modules

```tsx
// components/Button.module.css
.button { @apply px-4 py-2 rounded font-medium; }
.primary { @apply bg-primary text-white hover:bg-primary/90; }
```

```tsx
import styles from "./Button.module.css";
export function Button({ children, variant = "primary" }) {
  return (
    <button className={`${styles.button} ${styles[variant]}`}>
      {children}
    </button>
  );
}
```

---

## 9. Testing

### Vitest Unit/Integration

```tsx
// server-functions/posts.test.ts
import { createServerFn } from "@tanstack/start";
import { describe, it, expect, vi } from "vitest";

const createPost = createServerFn(
  "POST",
  z.object({ title: z.string() }),
).handler(async ({ data }) => ({ id: "1", ...data }));

describe("createPost", () => {
  it("creates post with valid data", async () => {
    const result = await createPost.handler({ data: { title: "Test" } });
    expect(result).toEqual({ id: "1", title: "Test" });
  });
});
```

### Component Testing

```tsx
// components/PostCard.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PostCard } from "./PostCard";

it("renders post and handles click", async () => {
  const onClick = vi.fn();
  render(<PostCard post={{ id: "1", title: "Hello" }} onClick={onClick} />);

  expect(screen.getByText("Hello")).toBeInTheDocument();
  await userEvent.click(screen.getByRole("button"));
  expect(onClick).toHaveBeenCalledWith("1");
});
```

### Playwright E2E

```ts
// tests/e2e/posts.spec.ts
import { test, expect } from "@playwright/test";

test("creates post via server function", async ({ page }) => {
  await page.goto("/posts/new");
  await page.getByLabel("Title").fill("E2E Test Post");
  await page.getByLabel("Content").fill("Content from Playwright");
  await page.getByRole("button", { name: "Create" }).click();

  await expect(page.getByText("E2E Test Post")).toBeVisible();
});
```

---

## 10. Deployment

### Docker

```dockerfile
# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/server/entry.mjs"]
```

### Vercel

```bash
# vercel.json
{
  "buildCommand": "pnpm build",
  "outputDirectory": "dist",
  "framework": "tanstack-start"
}
```

### Cloudflare Pages

```toml
# wrangler.toml
name = "my-app"
compatibility_date = "2024-01-01"
pages_build_output_dir = "dist"
```

### Static Export

```ts
// vinxi.config.ts
export default defineConfig({
  build: { preset: "static" },
});
```

---

## 11. Methodology

Before using ANY TanStack Start pattern not documented in this skill:

1. **MCP Context7** (priority): `context7_resolve-library-id` + `context7_query-docs` for TanStack Router, Query, Start, Vinxi.
2. **Official docs**: tanstack.com/router, tanstack.com/query, tanstack.com/start — verify current APIs.
3. **Project config**: `vinxi.config.ts`, `router.tsx`, `tsconfig.json` — verify against actual setup.
4. **HARD RULE**: If not in this skill AND cannot be verified against 2 authoritative sources → DO NOT USE IT. Document as assumption or risk in report to orchestrator.

---

## 12. Prohibitions

- ❌ Do not use `createServerFn` without Zod validation
- ❌ Do not skip auth middleware on mutating server functions
- ❌ Do not use `useQuery` in Server Components — use loaders
- ❌ Do not skip `queryClient.prefetchQuery` in loaders for linked routes
- ❌ Do not use `useState`/`useEffect` in Server Components
  — Client Components only
- ❌ Do not skip `queryClient.invalidateQueries` after mutations
- ❌ Do not use `any` in route loaders/server functions — strict typing
- ❌ Do not skip error boundaries on routes with loaders
- ❌ Do not use `fetch` directly in components — use TanStack Query or loaders
- ❌ Do not disable TanStack Router devtools in development

---

## 13. References

> **Note:** For React patterns, see [React](../reactjs/SKILL.md)
> **Note:** For TypeScript rules, see [TypeScript](../typescript/SKILL.md)
> **Note:** For JavaScript conventions, see [JavaScript](../javascript/SKILL.md)
> **Note:** For HTML/CSS conventions, see [HTML](../html/SKILL.md) / [CSS](../css/SKILL.md)
> **Note:** For Testing patterns, see [Testing](../testing/SKILL.md)
> **Note:** For Deployment, see [Deploy](../deploy/SKILL.md)

---

Last updated: 2026-08

