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.
Not because Next.js is bad. It’s not. Next.js is excellent software.
I switched because I got tired of feeling like every “best practice” quietly translated to: just use Vercel for this part too.
That was the breaking point.
The hidden contract in Next.js
Next.js presents itself as a framework decision, but in practice it often becomes a platform decision.
Can you deploy Next.js elsewhere? Yes. Can you make all the advanced features work exactly as intended everywhere? Sometimes. Can you do it without friction, edge-case behavior, or adapter caveats? Not always.
The docs, examples, and happy path are heavily optimized for one infrastructure story. You don’t just choose a router and rendering model. You inherit a runtime gravity well.
If you use the full modern feature set, the message starts to feel like this:
Use it on Vercel, or lose chunks of what makes it compelling.
That “use it or lose it” feeling is what pushed me out.
Why TanStack Start felt better immediately
TanStack Start felt like someone had separated two concerns that should have never been fused:
- App architecture
- 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/deployment layer that targets multiple platforms cleanly.
No single vendor narrative. No implicit pressure to align every infrastructure choice with the framework author’s cloud.
Superior design choices in TanStack Start
“Superior” is subjective, but there are design decisions here that I consider objectively healthier for long-term apps.
1) Server functions as a first-class primitive
TanStack Start’s server functions feel explicit and composable. The boundary between server and client is clear.
You don’t get magical behavior because a file is in a certain folder or because a component has a special rendering mode. You define server behavior directly, call it directly, and type-safety follows through.
That explicitness matters. It lowers framework folklore and increases codebase clarity.
2) Data loading built around actual state, not page ceremony
TanStack’s ecosystem has always been strong at state and data orchestration. Start inherits that DNA.
Data dependencies are declared closer to where they are used, invalidation is predictable, and cache behavior is something you can reason about instead of cargo-culting from scattered examples.
With Next.js, too many projects end up mixing mental models: server components, route handlers, client hooks, and partial cache knobs that interact in non-obvious ways.
Start feels more coherent.
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 can be handled with compile-time confidence instead of manually synchronizing strings across files. That reduces entire classes of bugs that normally 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/runtime layer so your app can target Node, serverless, edge-like environments, or platform-specific adapters without rewriting your whole stack.
That means your architecture can survive business changes:
- “We need to move providers”
- “We need a region this platform doesn’t support”
- “We need to cut infra cost this quarter”
Those become deployment conversations, not framework migration projects.
5) Better incentives for ownership
This is less about API surface and more about incentives.
TanStack Start + Nitro nudges you to own your architecture. Next.js often nudges you to adopt a platform bundle.
One path makes your system more legible over time. The other path can make it more convenient early, then more expensive to untangle later.
I prefer incentives that keep me in control.
Concrete code differences
Opinion is cheap. Here are a few real patterns where TanStack Start + 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: invalidation strategy is 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 are not rewriting app logic to chase infrastructure changes. You’re selecting a runtime adapter.
The Vercel question, honestly
Vercel is a good platform. For many teams, it is the correct choice.
This is not a “Vercel bad” argument.
This is a “framework design should not make platform dependence feel inevitable” argument.
I don’t want my runtime strategy to be determined by who authored my frontend framework. I don’t want feature parity to be political. I don’t want architectural decisions to quietly become billing decisions.
TanStack Start with Nitro gives me leverage back.
What I gained after switching
After moving, I noticed three practical improvements:
- Fewer framework-specific edge cases in day-to-day development
- Clearer data and server boundaries across the codebase
- More confidence that I can deploy where it makes technical and financial sense, not where documentation pressure points me
That last point is the big one.
Lock-in is not 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, huge hiring pool, and battle-tested defaults, you can absolutely succeed with it.
But if you care deeply about portable infrastructure, explicit architecture, and keeping framework choice separate from hosting choice, TanStack Start + 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. Nitro gave me the runtime flexibility I refused to give up.
That combination feels less like renting convenience and more like owning architecture.