I used Next.js for years. I shipped products with it, defended it in architecture meetings, and recommended it to friends. Then I switched to TanStack Start with Nitro.
Next.js isn’t bad; it’s excellent software. I switched because I got tired of feeling like every “best practice” quietly translated to just use Vercel for this part too, and that wore me down.
The hidden contract in Next.js
Next.js presents itself as a framework decision, but in practice it often becomes a platform decision. You can deploy Next.js elsewhere. You can sometimes make all the advanced features work exactly as intended everywhere. You can’t always do it without friction, edge-case behaviour, or adapter caveats.
The docs, examples, and happy path are heavily optimised for one infrastructure story, so you don’t just choose a router and a rendering model. You inherit a runtime gravity well. Use the full modern feature set and the message starts to feel like run it on Vercel, or lose chunks of what makes it compelling, and that’s what pushed me out.
Why TanStack Start felt better immediately
TanStack Start felt like someone had separated two concerns that should never have been fused: app architecture and hosting provider. That sounds obvious, but in frontend land it often isn’t.
With TanStack Start I got a routing and data model I actually enjoy, and with Nitro I got a runtime and deployment layer that targets multiple platforms cleanly. There’s no single-vendor narrative and no implicit pressure to align every infrastructure choice with the framework author’s cloud.
Superior design choices in TanStack Start
“Superior” is subjective, but a few of these design decisions are healthier for long-term apps.
1) Server functions as a first-class primitive
TanStack Start’s server functions are explicit and composable, and the boundary between server and client is clear.
You don’t get magical behaviour because a file sits in a certain folder or a component has a special rendering mode. You define server behaviour directly, call it directly, and type safety follows through. That explicitness lowers the amount of framework folklore you have to carry and makes the codebase clearer.
2) Data loading built around actual state, not page ceremony
TanStack’s ecosystem has always been strong at state and data orchestration, and Start inherits that.
Data dependencies are declared closer to where they’re used, invalidation is predictable, and you can reason about cache behaviour instead of cargo-culting it from scattered examples. Too many Next.js projects end up mixing mental models (server components, route handlers, client hooks, and partial cache knobs that interact in non-obvious ways), and Start feels more coherent by comparison.
3) Typed routing without awkward glue
TanStack Router has one of the best type-driven routing experiences in the JavaScript ecosystem.
Routes, params, and search state are handled with compile-time confidence instead of manually synchronising strings across files, which removes whole classes of bugs that usually slip into production through query-param drift and typo-level route mistakes. When the routing system is this strong, the rest of the app gets simpler by default.
4) Nitro decouples runtime from product decisions
Nitro is underrated. It gives you a portable server and runtime layer, so your app can target Node, serverless, edge-like environments, or platform-specific adapters without rewriting your whole stack.
That lets your architecture survive business changes. “We need to move providers,” “we need a region this platform doesn’t support,” and “we need to cut infra cost this quarter” become deployment conversations instead of framework migration projects.
5) Better incentives for ownership
This is less about API surface and more about incentives. TanStack Start plus Nitro nudges you to own your architecture, while Next.js often nudges you to adopt a platform bundle. One path makes your system more legible over time; the other is more convenient early and more expensive to untangle later. I prefer the incentives that keep me in control.
Concrete code differences
Opinion is cheap, so here are a few real patterns where TanStack Start plus Nitro feels cleaner to me.
Example 1: Server-side mutations
In Next.js, the common pattern is a server action mixed with cache/path revalidation concerns:
// Next.js
// app/actions/create-post.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = String(formData.get("title") || "");
await db.post.create({ data: { title } });
revalidatePath("/dashboard/posts");
}
// app/dashboard/posts/new/page.tsx
import { createPost } from "@/app/actions/create-post";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}
In TanStack Start, the server function and client invalidation model feel more explicit and composable:
// TanStack Start
// src/routes/dashboard/posts/new.tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { useMutation, useQueryClient } from "@tanstack/react-query";
const createPost = createServerFn({ method: "POST" })
.validator((input: { title: string }) => input)
.handler(async ({ data }) => {
return db.post.create({ data: { title: data.title } });
});
export const Route = createFileRoute("/dashboard/posts/new")({
component: NewPostPage,
});
function NewPostPage() {
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: (title: string) => createPost({ data: { title } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["posts"] }),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
mutation.mutate(String(form.get("title") || ""));
}}
>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}
The difference is subtle but important: the invalidation strategy lives in your data-layer mental model, not coupled to route-path cache semantics.
Example 2: Typed URL search state
Next.js often means manual parsing and validation of search params:
// Next.js
// app/search/page.tsx
export default async function SearchPage({
searchParams,
}: {
searchParams: { q?: string; page?: string };
}) {
const q = searchParams.q ?? "";
const page = Number(searchParams.page ?? "1");
if (Number.isNaN(page) || page < 1) {
throw new Error("Invalid page");
}
const results = await searchDocs({ q, page });
return <SearchResults q={q} page={page} results={results} />;
}
TanStack Router gives you route-level validation and typed access to search state:
// TanStack Start
// src/routes/search.tsx
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchSchema = z.object({
q: z.string().default(""),
page: z.coerce.number().int().min(1).default(1),
});
export const Route = createFileRoute("/search")({
validateSearch: searchSchema,
loader: async ({ search }) => searchDocs(search),
component: SearchPage,
});
function SearchPage() {
const search = Route.useSearch(); // fully typed: { q: string; page: number }
const results = Route.useLoaderData();
return <SearchResults q={search.q} page={search.page} results={results} />;
}
This removes a lot of ad-hoc parsing code and keeps URL state contracts in one place.
Example 3: Runtime portability
With Nitro, deployment target becomes a build output choice instead of a framework rewrite:
// nitro.config.ts
import { defineNitroConfig } from "nitropack/config";
export default defineNitroConfig({
preset: process.env.NITRO_PRESET || "node-server",
});
# Node server
NITRO_PRESET=node-server bun run build
# Cloudflare Workers
NITRO_PRESET=cloudflare bun run build
# AWS Lambda
NITRO_PRESET=aws-lambda bun run build
You aren’t rewriting app logic to chase infrastructure changes. You’re selecting a runtime adapter.
The Vercel question, honestly
Vercel is a good platform, and for many teams it’s the right choice. This isn’t a “Vercel bad” argument; it’s an argument that framework design shouldn’t make platform dependence feel inevitable.
I don’t want my runtime strategy decided by whoever authored my frontend framework, I don’t want feature parity to be political, and I don’t want architectural decisions to quietly become billing decisions. TanStack Start with Nitro gives me that leverage back.
What I gained after switching
After moving, I noticed three practical improvements: fewer framework-specific edge cases day to day, clearer data and server boundaries across the codebase, and more confidence that I can deploy where it makes technical and financial sense rather than where documentation pressure points me.
That last point is the big one. Lock-in isn’t just about whether you can move. It’s about whether your architecture makes moving cheap when you need to.
Who should still choose Next.js
If your team is all-in on Vercel and happy with that tradeoff, Next.js is still a strong option. If you want a giant ecosystem, a huge hiring pool, and battle-tested defaults, you can absolutely succeed with it.
But if you care about portable infrastructure, explicit architecture, and keeping framework choice separate from hosting choice, TanStack Start plus Nitro is a serious upgrade.
Final thought
I didn’t leave Next.js because it failed me. I left because I wanted my stack to age better. TanStack Start gave me the application model I wanted, and Nitro gave me the runtime flexibility I refused to give up. Together they feel less like renting convenience and more like owning my architecture.