← Back to list

TanStack Start: Catch Bugs Before Your Users Do

TanStack Start + tRPC: When TypeScript actually catches your mistakes before users do.

RUiNtheExtinct · 2026-01-23 11:33 · 3 claps · 17.5 min read
#tanstack #front-end-development #react #trpc #typescript
Open on Medium ↗
Wiki topics: 🌐 · Web Development

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:

With Clerk:

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

  1. End-to-end type safety is real — With tRPC, changes propagate instantly from server to client
  2. Routes are compile-time checked — Typos caught before deployment
  3. Client-first philosophy — Matches React’s mental model, unlike server-first frameworks
  4. tRPC for APIs — Eliminates an entire category of bugs
  5. Layouts for shared UI — Underscore prefix means no URL segment
  6. beforeLoad for guards — Never flash unauthenticated UI
  7. React Query integration — Best-in-class server state management

Resources

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:

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