TanStack Start — Rules and Conventions
1. Philosophy
- Type-safe by default — End-to-end type safety from routes to server functions to queries.
- Full-stack React — Single framework for client + server. No API layer duplication.
- Server-first rendering — SSR/SSG by default. Client hydration only when needed.
- TanStack ecosystem unified — Router + Query + Start work together seamlessly.
- Vinxi-powered — Built on Vinxi for server, SSR, streaming, and deployment flexibility.
- 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
# 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
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
// 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" },
});
// 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)
// 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)
// 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
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} => handleSort(e.target.value)}>
<option value="newest">Newest</option>
<option value="popular">Popular</option>
</select>
<button => handlePageChange(search.page + 1)}>Next</button>
</div>
);
}
Redirects & Error Boundaries
// 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
// 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
// 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
=> {
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
// 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
// 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
// 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,
});
// 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
// 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
// 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
// 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
// routes/posts.$postId.tsx
export const Route = createFileRoute("/posts/$postId")({
// Force static generation
// staticGenerate: true,
// Or force SSR
// prerender: false,
});
Streaming
// 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)
pnpm add -D tailwindcss postcss autoprefixer
pnpm tailwindcss init -p
/* styles/globals.css */
@import "tailwindcss";
@theme {
--color-primary: #0066cc;
--color-secondary: #6c757d;
}
// app.tsx or __root.tsx
import "./styles/globals.css";
CSS Modules
// components/Button.module.css
.button { @apply px-4 py-2 rounded font-medium; }
.primary { @apply bg-primary text-white hover:bg-primary/90; }
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
// 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
// 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
render(<PostCard post={{ id: "1", title: "Hello" }} />);
expect(screen.getByText("Hello")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledWith("1");
});
Playwright E2E
// 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
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
# vercel.json
{
"buildCommand": "pnpm build",
"outputDirectory": "dist",
"framework": "tanstack-start"
}
Cloudflare Pages
# wrangler.toml
name = "my-app"
compatibility_date = "2024-01-01"
pages_build_output_dir = "dist"
Static Export
// vinxi.config.ts
export default defineConfig({
build: { preset: "static" },
});
11. Methodology
Before using ANY TanStack Start pattern not documented in this skill:
- MCP Context7 (priority):
context7_resolve-library-id+context7_query-docsfor TanStack Router, Query, Start, Vinxi. - Official docs: tanstack.com/router, tanstack.com/query, tanstack.com/start — verify current APIs.
- Project config:
vinxi.config.ts,router.tsx,tsconfig.json— verify against actual setup. - 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
createServerFnwithout Zod validation - ❌ Do not skip auth middleware on mutating server functions
- ❌ Do not use
useQueryin Server Components — use loaders - ❌ Do not skip
queryClient.prefetchQueryin loaders for linked routes - ❌ Do not use
useState/useEffectin Server Components — Client Components only - ❌ Do not skip
queryClient.invalidateQueriesafter mutations - ❌ Do not use
anyin route loaders/server functions — strict typing - ❌ Do not skip error boundaries on routes with loaders
- ❌ Do not use
fetchdirectly in components — use TanStack Query or loaders - ❌ Do not disable TanStack Router devtools in development
13. References
Note: For React patterns, see React Note: For TypeScript rules, see TypeScript Note: For JavaScript conventions, see JavaScript Note: For HTML/CSS conventions, see HTML / CSS Note: For Testing patterns, see Testing Note: For Deployment, see Deploy
Last updated: 2026-08