Okay, confession. I started a side project in February on Next.js, hit a wall around the caching defaults, and instead of grinding through it I rage-quit and rebuilt the whole thing on TanStack Start. Three months later the app is in production, I’ve shipped two more features on top of it, and I have actual opinions about where the trade is worth it.
This is not the “TanStack Start killed Next.js” post. Next.js still ships most of my client work. But there’s a real chunk of apps where Start fits my brain better, and I want to walk through where the line is for me before I forget what convinced me.
Short version for the impatient: if your app is mostly authenticated, data-heavy, and you don’t need RSC streaming for SEO, give Start a serious look. If you’re building a marketing site, a content blog, or anything that depends on RSC payload streaming as a SEO feature, stay on Next.
TanStack Start is a router-first framework, not a Next.js clone
The first thing that confused me is that Start isn’t really competing on the same axis as Next.js. Next.js sells you a framework that happens to have a router. Start sells you a router that happens to be a framework. That sounds like a marketing distinction, but it changes how you build.
In Next.js, your file paths drive everything: a folder is a route, page.tsx is the entry, layout.tsx wraps it, and the framework decides which boundaries are server and which are client. In Start, you define routes as objects with a loader, a component, and optional beforeLoad. The whole tree is one typed graph, and your loaders’ return types flow into your components without a useQuery round trip.
Here’s the same “load user, render page” in both. Next.js 15 with the app router:
// app/dashboard/page.tsx
import { getUserFromCookie } from "@/lib/auth";
export default async function DashboardPage() {
const user = await getUserFromCookie();
return <h1>Hi {user.name}</h1>;
}
TanStack Start:
// src/routes/dashboard.tsx
import { createFileRoute } from "@tanstack/react-router";
import { getUserFromCookie } from "~/lib/auth";
export const Route = createFileRoute("/dashboard")({
loader: () => getUserFromCookie(),
component: Dashboard,
});
function Dashboard() {
const user = Route.useLoaderData();
return <h1>Hi {user.name}</h1>;
}
Both work. The Next.js one is shorter on the page. The Start one gives you the loader as a first-class hook you can refetch, invalidate, or subscribe to from any child. Six months in, the loader pattern is the thing I miss the most when I jump back into Next.js for client projects.
Where I migrated and what I kept on Next.js
The migration math is the boring part of this post, but I think it matters. My side project is a small internal tool: roughly 40 routes, mostly authenticated, lots of forms, no public SEO surface. Migrating took about a week of evenings. About 70% of that was rewriting app/ route folders into Start’s src/routes/ file structure and porting server actions to server functions. The rest was rewiring auth.
I did not migrate my actual client portfolio, which still runs on Next.js. The marketing pages there depend on generateStaticParams and RSC streaming for first paint on slow networks, and Start’s SSR story doesn’t ship the same RSC streaming yet. If you’ve ever fought with Next.js caching defaults and lived to tell about it, you know the price of leaving that ecosystem isn’t zero. The plugin and host story is just deeper.
So the rule I ended up with is: data app, internal tool, anything where I’m the only audience for the SSR markup, Start. Public-facing content site, marketing landing pages, anywhere I’m going to brag about Core Web Vitals, Next.
Server functions are the part I actually missed
This is the feature I didn’t know I wanted until I had it. In Next.js, you write a server action and call it from a form. Fine. The action lives in a "use server" file and you can also call it imperatively, but the type story gets weird the moment you want to call it from a loader or from the client outside a form.
Start’s server functions feel more like RPC done right. You define them once, they run on the server, and you can call them from a loader or from a button handler on the client. The types travel with the function, so I haven’t written a fetch wrapper in months.
// src/server/posts.ts
import { createServerFn } from "@tanstack/start";
import { z } from "zod";
import { db } from "~/lib/db";
export const getPosts = createServerFn({ method: "GET" })
.validator(z.object({ limit: z.number() }))
.handler(async ({ data }) => {
return db.post.findMany({ take: data.limit });
});
Then in a loader:
loader: () => getPosts({ data: { limit: 20 } }),
Or from a button click on the client, no extra boilerplate:
const onClick = async () => {
const fresh = await getPosts({ data: { limit: 50 } });
setPosts(fresh);
};
One definition. Validation and the return type live next to the handler. This is the part where I stopped writing custom /api/posts/route.ts files and just deleted them. I cover the same pattern from a different angle in my notes on tRPC in 2026, and the punchline is similar: when the boundary between client and server is a typed function, your brain stops context-switching between two languages.
Middleware that runs in the same type-safe world
Next.js middleware lives in middleware.ts at the project root, runs at the edge, and gets a NextRequest. It’s powerful, but it’s also a context all by itself: you can’t easily share state with your route handlers, and the type story is, let’s be polite, optimistic.
Start’s middleware composes with createMiddleware and runs through the same server-function pipeline. You can stack auth checks, rate limits, and logging, and each step can attach typed context that the next step receives. Here’s an auth middleware I actually use:
// src/server/middleware.ts
import { createMiddleware } from "@tanstack/start";
import { getUserFromCookie } from "~/lib/auth";
export const authMiddleware = createMiddleware()
.server(async ({ next }) => {
const user = await getUserFromCookie();
if (!user) throw new Error("unauthorized");
return next({ context: { user } });
});
Then I attach it to a server function:
export const deletePost = createServerFn({ method: "POST" })
.middleware([authMiddleware])
.validator(z.object({ id: z.string() }))
.handler(async ({ data, context }) => {
if (context.user.role !== "admin") throw new Error("forbidden");
return db.post.delete({ where: { id: data.id } });
});
The context.user is typed because the middleware said so. No re-fetching, no parallel auth call in the handler, no getServerSession boilerplate. This is the kind of small ergonomic win that compounds across forty routes.
If you want to read the actual spec, the TanStack Router middleware docs cover the full type-context flow, and the Start server functions reference is the canonical source for how the validators and middleware chain together.
What I’d still pick Next.js for
I want to push back on the “Start is just better” framing I keep seeing on Twitter, because it isn’t true for me. There are real reasons I still ship Next.js for client work, and I think it’s honest to list them.
First, RSC streaming. Next.js 15 streams the React Server Component payload over the wire and progressively hydrates, which is genuinely useful for public-facing pages where TTFB matters. Start does SSR, but its RSC story is younger and the streaming defaults are less aggressive. For an e-commerce product page, I’d still grab Next.
Second, the hosting story. Vercel is opinionated and expensive, but it’s also one CLI command to deploy and the cache layer Just Works. Start runs anywhere a Vite app runs, which is great in theory and a small project in practice. I deployed mine to Cloudflare Workers and it works, but I had to read the Cloudflare adapter docs to wire it up.
Third, the ecosystem. Next.js has a decade of Stack Overflow answers, every auth provider has a Next.js example in the README, and every “How do I do X in React?” tutorial assumes Next. Start is much smaller. When I hit a weird SSR cookie bug, I had to read source code, not blog posts. That’s fine for me, less fine for a team rotating in junior devs.
I’m not selling Start as a Next.js replacement. I’m saying it’s a better fit for a specific shape of app, and pretending otherwise would be cope.
How to try it without rewriting your whole app
If you want to test the waters without burning a weekend, I’d do this. Pick one internal tool you maintain, ideally something with a login wall and a couple of CRUD pages. Spin up a new Start project with npx create-tsrouter-app@latest, port two routes by hand, and write one server function. That’s enough to feel whether the loader-and-server-function pattern clicks for you.
If it does, I’d port the rest. If it doesn’t, you’ve spent two hours and learned what your real attachment to file-based RSC routing is, which is useful information either way.
I keep a running list of small framework experiments like this on my portfolio, and the pattern is always the same: pick a real app, give it a week, and write down the moment you wanted to throw the laptop. That moment is where the trade-off actually lives. Mine for Start was when I tried to deploy to a non-Vercel host. Yours might be different. Worth finding out before you bet a client project on it.