TanStack Start: Catch Bugs Before Your Users Do
TanStack Start + tRPC: When TypeScript actually catches your mistakes before users do.
TanStack Start: Catch Bugs Before Your Users Do
TanStack Start + tRPC: When TypeScript actually catches your mistakes before users do.

Introduction
I’ve shipped production apps with Next.js, Remix, and SvelteKit. They’re all good frameworks. But they all share one frustrating problem: the type safety illusion.
Sure, they support TypeScript. But try this: rename a route parameter in your API and see how long it takes to find all the broken client calls. TypeScript won’t help you. You’ll find them in production, usually from a user who’s angrier than you’d like.
TanStack Start is different. It’s the first React framework I’ve used where end-to-end type safety is actually real — not marketing, not “supported,” but genuinely enforced at compile time.
In this guide, I’ll show you how to build production-ready applications with TanStack Start, including authentication, multi-tenancy, tRPC integration for the ultimate type safety, and more.

Why TanStack Start?
The Type Safety Promise (Actually Delivered)
Here’s what “type-safe routing” means in other frameworks:
// Next.js - Compiles fine, breaks at runtime
<Link href="/users/123">View User</Link> // Typo: should be /user/123
// API Route
// pages/api/user/[id].ts
export default function handler(req, res) {
const { id } = req.query; // id is string | string[] | undefined
}
Here’s TanStack Start:
// TanStack Start - TypeScript ERROR at compile time
<Link to="/users/$id" params={{ id: '123' }}>View User</Link>
// ❌ Error: Route '/users/$id' does not exist. Did you mean '/user/$id'?
// Route params are typed correctly
function UserPage() {
const { id } = Route.useParams(); // id: string ✅ (guaranteed)
}
Beyond Routing: The Full Picture
[embed]
That last point matters: TanStack Start is client-first. The server enhances your app; it doesn’t own it. This means faster iterations, better DX, and a mental model that matches how React actually works.
Quick Start
Create Your Project
npm create @tanstack/start@latest my-app
cd my-app
npm install
npm run dev
Or use the community template with everything pre-configured:
# react-tanstarter: Better Auth + Drizzle + shadcn/ui + Tailwind
npx degit dotnize/react-tanstarter my-app
cd my-app
npm install
npm run dev
Project Structure
my-app/
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout (HTML shell)
│ │ ├── index.tsx # / (home)
│ │ ├── _authenticated.tsx # Protected layout
│ │ └── _authenticated/
│ │ ├── dashboard.tsx # /dashboard
│ │ └── $workspaceId/ # /:workspaceId (multi-tenant)
│ │ ├── index.tsx # /:workspaceId
│ │ └── settings.tsx # /:workspaceId/settings
│ ├── lib/
│ │ ├── trpc.ts # tRPC client
│ │ └── server-fns.ts # Server functions
│ └── router.tsx # Router config
├── server/
│ └── trpc/ # tRPC routers
└── app.config.ts
File Naming = Routes
[embed]
Core Concepts
1. Type-Safe Routes
// src/routes/blog/$postId.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/blog/$postId')({
component: BlogPost,
});
function BlogPost() {
// postId is typed as string - guaranteed to exist
const { postId } = Route.useParams();
return <h1>Post: {postId}</h1>;
}
Navigation is also type-safe:
import { Link, useNavigate } from '@tanstack/react-router';
// ✅ TypeScript validates params
<Link to="/blog/$postId" params={{ postId: '42' }}>
Read Post
</Link>
// ✅ Programmatic navigation
const navigate = useNavigate();
navigate({ to: '/blog/$postId', params: { postId: '42' } });
// ❌ TypeScript error - missing required param
<Link to="/blog/$postId">Read Post</Link>
2. Validated Search Params
// src/routes/products.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
const searchSchema = z.object({
category: z.enum(['electronics', 'clothing', 'books']).optional(),
minPrice: z.coerce.number().min(0).optional(),
maxPrice: z.coerce.number().max(10000).optional(),
page: z.coerce.number().min(1).default(1),
sort: z.enum(['price-asc', 'price-desc', 'newest']).default('newest'),
});
type ProductSearch = z.infer<typeof searchSchema>;
export const Route = createFileRoute('/products')({
validateSearch: (search): ProductSearch => searchSchema.parse(search),
component: ProductsPage,
});
function ProductsPage() {
// All params are typed and validated!
const { category, minPrice, maxPrice, page, sort } = Route.useSearch();
return (
<div>
<p>Category: {category ?? 'all'}</p>
<p>Price: ${minPrice ?? 0} - ${maxPrice ?? '∞'}</p>
<p>Page: {page}, Sort: {sort}</p>
</div>
);
}
// Type-safe navigation with search params
<Link
to="/products"
search={{
category: 'electronics',
minPrice: 100,
page: 2,
sort: 'price-asc'
}}
>
Electronics over $100
</Link>
3. Layouts and Protected Routes
// src/routes/_authenticated.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router';
import { getSession } from '~/lib/auth';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async () => {
const session = await getSession();
if (!session) {
throw redirect({
to: '/login',
search: { redirect: window.location.pathname },
});
}
return { user: session.user };
},
component: AuthenticatedLayout,
});
function AuthenticatedLayout() {
const { user } = Route.useRouteContext();
return (
<div className="flex min-h-screen">
<Sidebar user={user} />
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
);
}
All routes under _authenticated/ now require authentication:
src/routes/
├── _authenticated.tsx # Auth guard
└── _authenticated/
├── dashboard.tsx # /dashboard (protected)
├── settings.tsx # /settings (protected)
└── $workspaceId/ # /:workspaceId (protected)
└── index.tsx
4. Data Loading with React Query
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
// Prefetch into React Query cache
await queryClient.ensureQueryData({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60_000, // 1 minute
});
},
component: PostsPage,
});
function PostsPage() {
// Data already in cache - instant render
const { data: posts } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
End-to-End Type Safety with tRPC
Here’s where TanStack Start really shines. Combine it with tRPC for true end-to-end type safety — change a return type on the server, and TypeScript errors appear in every affected client component instantly.
Setting Up tRPC
npm install @trpc/server @trpc/client @trpc/react-query superjson
// server/trpc/index.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { z } from 'zod';
import { db } from '~/lib/db';
import { getSession } from '~/lib/auth';
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
// Protected procedure - requires authentication
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, session: ctx.session } });
});
// Workspace procedure - requires workspace membership
export const workspaceProcedure = protectedProcedure
.input(z.object({ workspaceId: z.string() }))
.use(async ({ ctx, input, next }) => {
const membership = await db.membership.findUnique({
where: {
userId_workspaceId: {
userId: ctx.session.user.id,
workspaceId: input.workspaceId,
},
},
});
if (!membership) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next({
ctx: { ...ctx, membership, workspaceId: input.workspaceId },
});
});
Defining Your API
// server/trpc/routers/posts.ts
import { z } from 'zod';
import { router, protectedProcedure, workspaceProcedure } from '../index';
export const postsRouter = router({
// List posts for a workspace
list: workspaceProcedure
.input(z.object({
status: z.enum(['draft', 'published']).optional(),
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const posts = await db.post.findMany({
where: {
workspaceId: ctx.workspaceId,
...(input.status && { status: input.status }),
},
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
const hasMore = posts.length > input.limit;
const items = hasMore ? posts.slice(0, -1) : posts;
return {
items,
nextCursor: hasMore ? items[items.length - 1].id : null,
};
}),
// Get single post
byId: workspaceProcedure
.input(z.object({ postId: z.string() }))
.query(async ({ ctx, input }) => {
const post = await db.post.findUnique({
where: { id: input.postId, workspaceId: ctx.workspaceId },
include: { author: true, comments: true },
});
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return post;
}),
// Create post
create: workspaceProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
status: z.enum(['draft', 'published']).default('draft'),
}))
.mutation(async ({ ctx, input }) => {
return db.post.create({
data: {
...input,
workspaceId: ctx.workspaceId,
authorId: ctx.session.user.id,
},
});
}),
// Update post
update: workspaceProcedure
.input(z.object({
postId: z.string(),
title: z.string().min(1).max(200).optional(),
content: z.string().min(1).optional(),
status: z.enum(['draft', 'published']).optional(),
}))
.mutation(async ({ ctx, input }) => {
const { postId, ...data } = input;
return db.post.update({
where: { id: postId, workspaceId: ctx.workspaceId },
data,
});
}),
// Delete post
delete: workspaceProcedure
.input(z.object({ postId: z.string() }))
.mutation(async ({ ctx, input }) => {
await db.post.delete({
where: { id: input.postId, workspaceId: ctx.workspaceId },
});
return { success: true };
}),
});
Root Router
// server/trpc/routers/_app.ts
import { router } from '../index';
import { postsRouter } from './posts';
import { workspacesRouter } from './workspaces';
import { usersRouter } from './users';
export const appRouter = router({
posts: postsRouter,
workspaces: workspacesRouter,
users: usersRouter,
});
export type AppRouter = typeof appRouter;
Client Setup
Update (Feb 2025): tRPC released a new TanStack Query integration that’s more TanStack Query-native. The pattern below still works, but consider the newer
queryOptions-based approach for new projects.
// src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '../../server/trpc/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
export function createTRPCClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
});
}
Alternative: New QueryOptions Pattern (Recommended)
// src/lib/trpc.ts - Using the newer pattern
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/trpc/routers/_app';
import superjson from 'superjson';
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
});
// Usage with TanStack Query
import { useQuery } from '@tanstack/react-query';
// Instead of trpc.posts.list.useQuery(), use:
const { data } = useQuery(trpc.posts.list.queryOptions({ workspaceId }));
Using tRPC in Components
// src/routes/_authenticated/$workspaceId/posts.tsx
import { createFileRoute } from '@tanstack/react-router';
import { trpc } from '~/lib/trpc';
export const Route = createFileRoute('/_authenticated/$workspaceId/posts')({
component: PostsPage,
});
function PostsPage() {
const { workspaceId } = Route.useParams();
// Fully typed! Hover over 'data' to see the exact shape
const { data, isLoading, fetchNextPage, hasNextPage } =
trpc.posts.list.useInfiniteQuery(
{ workspaceId, limit: 20 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.posts.list.invalidate({ workspaceId });
},
});
const utils = trpc.useUtils();
if (isLoading) return <LoadingSkeleton />;
return (
<div>
<CreatePostForm
onSubmit={(values) => createPost.mutate({ workspaceId, ...values })}
isLoading={createPost.isPending}
/>
{data?.pages.flatMap((page) =>
page.items.map((post) => (
<PostCard key={post.id} post={post} />
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load More</button>
)}
</div>
);
}
The magic: Change the posts.list return type in your router, and TypeScript immediately shows errors everywhere that type is used. No runtime surprises.
Solving Real Engineering Problems
Problem 1: Authentication (Better Auth)
// src/lib/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from './db';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: { enabled: true },
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [
// Add TanStack Start cookie handling
tanstackStartCookies(),
],
});
export type Session = typeof auth.$Infer.Session;
// src/routes/api/auth/$.ts - Catch-all auth handler
import { createFileRoute } from '@tanstack/react-router';
import { auth } from '~/lib/auth';
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
});
// Frontend auth hook
import { authClient } from '~/lib/auth-client';
function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async () => {
await authClient.signIn.email({ email, password });
};
return (
<form onSubmit={handleLogin}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Sign In</button>
</form>
);
}
Problem 2: Multi-Tenancy (Path-Based)
// src/routes/_authenticated/$workspaceId.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { trpc } from '~/lib/trpc';
export const Route = createFileRoute('/_authenticated/$workspaceId')({
beforeLoad: async ({ params, context }) => {
// Verify workspace access
const workspace = await context.trpc.workspaces.byId.fetch({
workspaceId: params.workspaceId,
});
if (!workspace) {
throw redirect({ to: '/workspaces' });
}
return { workspace };
},
component: WorkspaceLayout,
});
function WorkspaceLayout() {
const { workspace } = Route.useRouteContext();
const { workspaceId } = Route.useParams();
return (
<div className="flex">
<WorkspaceSidebar workspace={workspace} />
<main className="flex-1">
<WorkspaceHeader workspace={workspace} />
<Outlet />
</main>
</div>
);
}
Problem 3: Form Handling with Validation
// Using TanStack Form with server validation
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
import { z } from 'zod';
const postSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(10, 'Content must be at least 10 characters'),
status: z.enum(['draft', 'published']),
});
function CreatePostForm({ workspaceId }: { workspaceId: string }) {
const createPost = trpc.posts.create.useMutation();
const form = useForm({
defaultValues: { title: '', content: '', status: 'draft' as const },
validatorAdapter: zodValidator(),
validators: {
onChange: postSchema,
},
onSubmit: async ({ value }) => {
await createPost.mutateAsync({ workspaceId, ...value });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field
name="title"
children={(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<span className="text-red-500">{field.state.meta.errors}</span>
)}
</div>
)}
/>
<form.Field
name="content"
children={(field) => (
<div>
<textarea
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors && (
<span className="text-red-500">{field.state.meta.errors}</span>
)}
</div>
)}
/>
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Problem 4: SEO & Meta Tags
// src/routes/blog/$slug.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => {
const post = await fetchPost(params.slug);
return { post };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.post.title },
{ name: 'description', content: loaderData.post.excerpt },
{ property: 'og:title', content: loaderData.post.title },
{ property: 'og:description', content: loaderData.post.excerpt },
{ property: 'og:image', content: loaderData.post.coverImage },
{ property: 'og:type', content: 'article' },
{ name: 'twitter:card', content: 'summary_large_image' },
],
}),
component: BlogPost,
});
Problem 5: Error Boundaries
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router';
export const Route = createRootRoute({
component: RootLayout,
errorComponent: GlobalErrorBoundary,
notFoundComponent: NotFoundPage,
});
function GlobalErrorBoundary({ error }: { error: Error }) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-gray-600">{error.message}</p>
<button onClick={() => window.location.reload()}>
Try again
</button>
</div>
</div>
);
}
function NotFoundPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">404</h1>
<p>Page not found</p>
<Link to="/">Go home</Link>
</div>
</div>
);
}
Problem 6: API Routes & Webhooks
// src/routes/api/webhooks/stripe.ts
import { createFileRoute } from '@tanstack/react-router';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const Route = createFileRoute('/api/webhooks/stripe')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.text();
const signature = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object);
break;
}
return new Response('OK', { status: 200 });
},
},
},
});
Problem 7: Optimistic Updates
function PostList({ workspaceId }: { workspaceId: string }) {
const utils = trpc.useUtils();
const deletePost = trpc.posts.delete.useMutation({
// Optimistic update
onMutate: async ({ postId }) => {
// Cancel outgoing refetches
await utils.posts.list.cancel({ workspaceId });
// Snapshot previous value
const previousData = utils.posts.list.getData({ workspaceId });
// Optimistically remove the post
utils.posts.list.setData({ workspaceId }, (old) => ({
...old!,
items: old!.items.filter((p) => p.id !== postId),
}));
return { previousData };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousData) {
utils.posts.list.setData({ workspaceId }, context.previousData);
}
},
onSettled: () => {
// Refetch after mutation
utils.posts.list.invalidate({ workspaceId });
},
});
// ...
}
Problem 8: File Uploads
// Server function for presigned URLs
import { createServerFn } from '@tanstack/react-start/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export const getUploadUrl = createServerFn('POST', async (data: {
fileName: string;
fileType: string;
workspaceId: string;
}) => {
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const s3 = new S3Client({ region: process.env.AWS_REGION });
const key = `${data.workspaceId}/${Date.now()}-${data.fileName}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: data.fileType,
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return { uploadUrl, key };
});
// Frontend upload component
async function uploadFile(file: File, workspaceId: string) {
const { uploadUrl, key } = await getUploadUrl({
fileName: file.name,
fileType: file.type,
workspaceId,
});
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
return `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`;
}
Problem 9: Real-Time Data
// Using React Query with polling or WebSocket subscriptions
function LiveDashboard({ workspaceId }: { workspaceId: string }) {
const { data: stats } = trpc.analytics.dashboard.useQuery(
{ workspaceId },
{
refetchInterval: 5000, // Poll every 5 seconds
}
);
// Or with WebSocket subscription
trpc.notifications.onNew.useSubscription(
{ workspaceId },
{
onData: (notification) => {
toast.success(notification.message);
},
}
);
return <DashboardStats stats={stats} />;
}
Problem 10: Preloading & Performance
// Preload on hover
import { useRouter } from '@tanstack/react-router';
function PostCard({ post }: { post: Post }) {
const router = useRouter();
return (
<Link
to="/blog/$postId"
params={{ postId: post.id }}
preload="intent" // Preload on hover/focus
onMouseEnter={() => {
// Also prefetch tRPC data
utils.posts.byId.prefetch({ postId: post.id });
}}
>
{post.title}
</Link>
);
}
Common Gotchas
1. Route File Names Must Be Lowercase
❌ routes/BlogPost.tsx → Won't work
✅ routes/blog-post.tsx → /blog-post
✅ routes/blog/$postId.tsx → /blog/:postId
2. Auth Checks Go in beforeLoad
If you do auth checks in the component, users see protected content flash before the redirect. Very reassuring for enterprise clients.
// ❌ BAD - UI flashes before redirect
function Dashboard() {
const { data: session } = useSession();
if (!session) redirect('/login'); // Too late
}
// ✅ GOOD - Blocks render entirely
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
const session = await getSession();
if (!session) throw redirect({ to: '/login' });
return { session };
},
});
3. Search Params Need Parsing
// URL: /search?page=2
// ❌ page is string "2"
const search = Route.useSearch();
console.log(search.page + 1); // "21"
// ✅ Parse with Zod
validateSearch: (search) => z.object({
page: z.coerce.number().default(1)
}).parse(search)
4. Server Functions vs tRPC
Use server functions for simple one-off operations:
const getTheme = createServerFn('GET', async () => {
return cookies().get('theme') ?? 'light';
});
Use tRPC for your application API:
// Complex, type-safe, with middleware
trpc.posts.create.useMutation()
The TanStack Ecosystem: More Than Just a Router
TanStack Start is part of a broader ecosystem of type-safe, headless libraries that all work together seamlessly. Here’s the full picture:
Core Libraries (All Type-Safe, All Excellent)
LibraryPurposeWhy It’s GreatTanStack RouterRoutingCompile-time route checking, file-based, layoutsTanStack QueryServer stateCaching, deduplication, background refetch, infinite queriesTanStack FormForm handlingField-level validation, async validation, adapters for Zod/YupTanStack TableData tablesSorting, filtering, pagination, virtualization, 100% headlessTanStack VirtualVirtualizationRender millions of rows, horizontal/vertical/grid
The tRPC Advantage: Why It Changes Everything
tRPC is why TanStack Start matters.
Without tRPC, you have type-safe routes. That’s nice. It’s also not enough.
With tRPC, you have type-safe everything — from database query to UI component. Change a field name in your Prisma schema, and TypeScript errors cascade through your entire app, showing you exactly what to fix.
// Change this in your database schema:
// username -> displayName
// tRPC procedure immediately shows error:
// Property 'username' does not exist on type 'User'
// Every component using that field shows errors:
// <span>{user.username}</span>
// ^^^^^^^^ Error!
// You fix them all before running the app. Zero runtime errors.
This isn’t theoretical. I’ve done refactors that would have taken days of testing in a traditional REST setup. With tRPC, it took an hour.
The Type Safety Stack (My Recommendation)
For maximum type safety, use this combination:
TanStack Start (routes)
↓
tRPC (API layer)
↓
Drizzle ORM (database)
↓
PostgreSQL
Every layer is fully typed. Changes propagate through the entire stack.
// Change in Drizzle schema
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull(),
displayName: text('display_name'), // renamed from 'name'
});
// tRPC router shows error
return db.select().from(users).where(eq(users.name, input.name));
// ^^^^ Error: Property 'name' does not exist
// Fix ripples through to components automatically
Comparison: TanStack Start + tRPC vs Alternatives
[embed]
*Next.js 15 has improved type safety for route params with
next/navigation, though it's not as comprehensive as TanStack Router's compile-time checking.
Trade-off to consider: If you need React Server Components today, Next.js is the mature choice. TanStack Start’s RSC support is coming as a non-breaking v1.x addition, but isn’t available yet.
Official Templates
Official templates and starters:
Official Starter:
- TanStack Start basic
- Get started:
npm create @tanstack/start@latest
react-tanstarter:
- Better Auth, Drizzle, shadcn
- Get started:
npx degit dotnize/react-tanstarter
With tRPC:
- TanStack Start + tRPC
- Get started: tanstack.com/examples
With Clerk:
- Auth with Clerk
- Get started: Example repo
Convex SaaS:
- TanStack + Convex + Stripe
- Get started: github.com/get-convex/convex-saas
The react-tanstarter template is particularly good — it includes Better Auth, Drizzle, shadcn/ui, and Tailwind v4 pre-configured. You can go from npx degit to building features in under 5 minutes.
Deploying to Production
TanStack Start is framework-agnostic for deployment — it can run anywhere JavaScript runs.
Vercel (Recommended, Zero-Config)
npm i -g vercel
vercel
Vercel auto-detects TanStack Start and configures:
- Server functions → Serverless functions
- Static assets → Edge CDN
- API routes → API endpoints
Environment variables go in the Vercel dashboard or .env.production.
Netlify (Official Partner)
npm install @netlify/vite-plugin-tanstack-start -D
// app.config.ts
import { defineConfig } from '@tanstack/start/config';
import netlifyPlugin from '@netlify/vite-plugin-tanstack-start';
export default defineConfig({
vite: {
plugins: [netlifyPlugin()],
},
});
netlify deploy --prod
Netlify provides full production platform emulation in local dev — what you see locally is what you get in production.
Cloudflare Workers/Pages
npm run build
wrangler pages deploy .output/public
Tip: Run wrangler pages dev first to verify everything works on Cloudflare's network before deploying.
Node.js Server (Self-Hosted)
npm run build
node .output/server/index.mjs
Works with PM2, Docker, Railway, Render, Fly.io, or any Node.js hosting.
Environment Variables
Production secrets go in your deployment platform. Access them in server functions and tRPC procedures:
// Server-only code
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const db = drizzle(process.env.DATABASE_URL!);
For client-side env vars (rare, but sometimes needed), prefix with VITE_:
VITE_PUBLIC_API_URL=https://api.example.com
Self-Hosting: Full Control Over Your Infrastructure
Good news: TanStack Start is fully self-hostable. It compiles to standard Node.js (or static files for SPA mode), meaning you can run it anywhere — your own servers, your own VPS, your own Kubernetes cluster. You own everything.
Why Self-Host?
Self-hosting makes sense when you need:
- Data sovereignty — Keep all data in your region/infrastructure
- Air-gapped environments — Restricted networks with no external dependencies
- Cost optimization — Predictable costs at scale (no per-request pricing)
- Full control — Your servers, your rules, your monitoring
Option 1: Docker (Recommended)
The cleanest approach for self-hosting.
# Dockerfile
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy built output
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/package*.json ./
# Install production dependencies only
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=myapp
volumes:
postgres_data:
# Deploy
docker compose up -d --build
Option 2: PM2 on a VPS
For simpler deployments on a single server.
# Build the app
npm run build
# Install PM2 globally
npm install -g pm2
# Start with PM2
pm2 start .output/server/index.mjs --name my-tanstack-app
# Auto-restart on server reboot
pm2 startup
pm2 save
// ecosystem.config.js (optional, for more control)
module.exports = {
apps: [{
name: 'my-tanstack-app',
script: '.output/server/index.mjs',
instances: 'max', // Use all CPU cores
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
}],
};
Option 3: Reverse Proxy (nginx or Caddy)
You’ll want a reverse proxy for SSL, caching, and load balancing:
nginx:
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name myapp.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name myapp.com;
ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Cache static assets
location /_build/ {
proxy_pass http://localhost:3000;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Caddy (simpler, auto-SSL):
# Caddyfile
myapp.com {
reverse_proxy localhost:3000
# Caddy handles SSL automatically
}
Option 4: Kubernetes
For scale-out deployments.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: tanstack-app
spec:
replicas: 3
selector:
matchLabels:
app: tanstack-app
template:
metadata:
labels:
app: tanstack-app
spec:
containers:
- name: app
image: your-registry/tanstack-app:latest
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: tanstack-app
spec:
selector:
app: tanstack-app
ports:
- port: 80
targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tanstack-app
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- myapp.com
secretName: tanstack-app-tls
rules:
- host: myapp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tanstack-app
port:
number: 80
Self-Hosting Checklist
Before going to production, ensure you have:
- SSL/TLS — Use Let’s Encrypt (free) or your own certs
- Process manager — PM2, systemd, or container orchestration
- Health checks — Endpoint for load balancer health monitoring
- Logging — Structured logs shipped to aggregation service
- Monitoring — Application metrics (Prometheus, Datadog, etc.)
- Backups — Database backup strategy (pg_dump, etc.)
- Secrets management — Env vars via Vault, AWS Secrets Manager, or similar
- CI/CD pipeline — Automated builds and deployments
When to Self-Host vs. Use Managed Platforms
[embed]
My recommendation: Start with Vercel or Netlify. Move to self-hosting when you have a specific reason — compliance, cost optimization at scale, or a masochistic streak.
Key Takeaways
- End-to-end type safety is real — With tRPC, changes propagate instantly from server to client
- Routes are compile-time checked — Typos caught before deployment
- Client-first philosophy — Matches React’s mental model, unlike server-first frameworks
- tRPC for APIs — Eliminates an entire category of bugs
- Layouts for shared UI — Underscore prefix means no URL segment
- beforeLoad for guards — Never flash unauthenticated UI
- React Query integration — Best-in-class server state management
Resources
- TanStack Start: tanstack.com/start
- TanStack Router: tanstack.com/router
- tRPC: trpc.io
- Better Auth: better-auth.com
- react-tanstarter: github.com/dotnize/react-tanstarter
- Examples: github.com/TanStack/router/examples
Conclusion
TanStack Start represents a new generation of React frameworks — one that finally delivers on the type safety promise. Combined with tRPC, you get something genuinely special: an application where types flow seamlessly from database to UI, where refactoring is safe, and where entire categories of runtime errors simply can’t happen.
The client-first philosophy means you’re writing React the way React was meant to be written, with server capabilities enhancing your app rather than constraining it.
Give it a try. Build something. Experience what true type safety feels like.
What to Read Next
If you found this useful:
- **TanStack Start + Convex** — Add real-time backend to your type-safe frontend
- **tRPC Documentation** — Master end-to-end type safety
- **Better Auth Guide** — Production authentication patterns
Questions? Drop them in the comments. I read every one.
Now go build something.
메타데이터
- post_id
- 530e66155ec4
- slug
- tanstack-start-catch-bugs-before-your-users-do-530e66155ec4
- url
- https://medium.com/@ruintheextinct/tanstack-start-catch-bugs-before-your-users-do-530e66155ec4
- canonical_url
- https://medium.com/@ruintheextinct/tanstack-start-catch-bugs-before-your-users-do-530e66155ec4
- author_url
- https://medium.com/@ruintheextinct
- status
- ok
- fetched_at
- 2026-06-21 19:25:17