Back to blog
engineering
frontend
TanStack
TanStack Start in Production: An Honest Review

TanStack Start in Production: An Honest Review

We rebuilt the ellix.ai company site with TanStack Start. Here's what worked, what didn't, and whether we'd do it again.

ellix.ai TeamMay 2, 20267 min read

TanStack Start in Production: An Honest Review

We used TanStack Start to build this website. After shipping it, here's a complete breakdown of the experience — including code, deployment, TypeScript ergonomics, and an honest comparison against Next.js App Router and Remix.

What TanStack Start Actually Is

TanStack Start is a full-stack React framework built on TanStack Router, with SSR handled by a Vite + Nitro layer. It's not a Next.js fork or a Remix-style framework — it's a distinct approach that prioritizes type-safety end-to-end, from route params through loader data to component props.

If you've used TanStack Router in a SPA, Start is what happens when you add SSR and server functions to that foundation. If you haven't used TanStack Router, expect a steeper learning curve than the framework's surface area suggests — the type machinery underneath is sophisticated.

A Full Route Definition: Loader, Head, and Component

Here's what a real content route looks like on this site — the blog post page:

import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/start";
import { getBlogPost } from "~/lib/blog";

// Server function — runs only on the server, callable from loaders or actions
const fetchPost = createServerFn({ method: "GET" })
  .validator((data: { slug: string }) => data)
  .handler(async ({ data }) => {
    const post = await getBlogPost(data.slug);
    if (!post) throw new Error(`Post not found: ${data.slug}`);
    return post;
  });

export const Route = createFileRoute("/blog/$slug")({
  loader: async ({ params }) => {
    return fetchPost({ data: { slug: params.slug } });
  },

  head: ({ loaderData }) => ({
    meta: [
      { title: loaderData.title },
      { name: "description", content: loaderData.description },
      { property: "og:title", content: loaderData.title },
      { property: "og:image", content: loaderData.image },
    ],
  }),

  component: BlogPostPage,
  notFoundComponent: () => <div>Post not found</div>,
  errorComponent: ({ error }) => <div>Error: {error.message}</div>,
});

function BlogPostPage() {
  const post = Route.useLoaderData();
  // post is fully typed — TypeScript infers the return type of fetchPost
  return <article>{/* ... */}</article>;
}

The head function receives loaderData — the same typed data the component receives. SSR meta tags are driven by the same loader, with no duplication. This is genuinely better than Next.js's generateMetadata pattern, which requires you to re-fetch data that the page component also needs.

The Server Function Model vs. tRPC and Next.js API Routes

TanStack Start's createServerFn is conceptually closer to Remix's action/loader model than to Next.js API routes or tRPC:

| Model | Colocated? | Type-safe? | Callable from | RPC style? | |---|---|---|---|---| | Next.js API routes | No | No (manually) | Any fetch | No | | Next.js Server Actions | Yes | Yes | React components | Kind of | | tRPC | Separate router file | Yes | TanStack Query | Yes | | Remix loaders/actions | Yes | Yes | Router | No | | TanStack Start server fns | Yes | Yes | Loaders, components | Yes |

Server functions compile away to HTTP endpoints under the hood. They're called with typed arguments, they return typed values, and the TypeScript compiler verifies both call sites. There's no schema definition layer (like Zod in tRPC) required — though you can add .validator() for runtime validation.

The practical difference from tRPC: server functions are colocated with the routes that use them, not in a centralized router. This is better for per-page data requirements. For shared API functionality, you can still define server functions in a lib file and import them.

Routing Conventions and the Generated Route Tree

TanStack Start uses file-based routing via TanStack Router's @tanstack/router-vite-plugin. Routes live in app/routes/. The plugin generates a routeTree.gen.ts file that you commit to your repo — this is the type source for all route params, loader data, and search params across the app.

Key conventions:

  • app/routes/index.tsx/
  • app/routes/blog/index.tsx/blog
  • app/routes/blog/$slug.tsx/blog/:slug
  • app/routes/blog_/$slug.tsx/blog/:slug (pathless layout)
  • app/routes/_auth.tsx → Layout route (no path segment, renders children)

The generated route tree means TypeScript errors at link sites when you rename a route. <Link to="/blgo/$slug"> is a type error because /blgo doesn't exist. This is one of the most underappreciated features of the router — typos in navigation never reach production.

Deploying to Different Targets

Nitro handles deployment target adapters. Switch targets by changing your app.config.ts:

// Bun (default for development)
import { defineConfig } from "@tanstack/start/config";
export default defineConfig({ server: { preset: "bun" } });

// Node.js
export default defineConfig({ server: { preset: "node-server" } });

// Cloudflare Workers
export default defineConfig({ server: { preset: "cloudflare-pages" } });

// Vercel
export default defineConfig({ server: { preset: "vercel" } });

We deploy to a Bun server on a VPS. Build output is a single directory you start with bun run .output/server/index.mjs. Cold start is under 100ms. Memory footprint is ~80MB for this site.

Cloudflare Workers deployment works but has constraints: no Node.js built-ins, no filesystem access, edge-runtime limitations. If you're using fs anywhere in your server functions — for reading MDX files, for example — the Cloudflare target requires a different approach (R2 or a precompiled content manifest).

TypeScript Experience: The Generics Around Loaders

The TypeScript story is the strongest part of TanStack Start and also the part most likely to produce confusing errors for new users. The route type machinery uses conditional types and infer heavily. When something is misconfigured, the error messages can be intimidating.

The patterns that trip people up most often:

// WRONG — accessing loaderData outside the route component
function BlogLayout() {
  // This doesn't work — loaderData isn't in scope here
  const data = Route.useLoaderData();
}

// RIGHT — useLoaderData() is only available inside Route.component
// Use the Route context from the router for layout data
function BlogLayout() {
  const { slug } = Route.useParams(); // params are in scope
}

The payoff for learning the type constraints: you can refactor an entire route's data shape and TypeScript will surface every callsite that needs updating. We've done full pricing page redesigns where the type errors showed us exactly which components needed changes.

Honest Comparison vs. Next.js App Router and Remix

| | TanStack Start | Next.js App Router | Remix | |---|---|---|---| | Type-safe routing | Yes — params, loader data, links | Partial — manual typing | Partial — useLoaderData is typed | | File-based routing | Yes | Yes | Yes | | SSR model | Loader + server fn | Server components + actions | Loader + action | | Meta/head tags | head() fn with typed loader data | generateMetadata (re-fetches data) | meta export | | Ecosystem maturity | Young | Mature | Mature | | Deployment targets | Multiple via Nitro | Vercel-first, self-hostable | Multiple | | Learning curve | High (router concepts) | Medium | Medium | | React Server Components | No | Yes | No |

The case for TanStack Start: you want end-to-end type safety that extends to link to props and loader shapes, and you're willing to invest in the learning curve. The case against: you need RSC, you want the widest possible community support, or your team knows Next.js well and the switching cost isn't justified.

We'd choose TanStack Start again for this website. For the aiassist.chat dashboard — which has complex data requirements and a large team — we'd evaluate carefully against Next.js App Router before committing.