
Migrating from Next.js or Remix to TanStack Start 1.0: Where Existing Assumptions Break
Introduction — Why a 1.0 Matters More Than a 0.x Bump
This post is a migration map for teams moving from Next.js or Remix to TanStack Start 1.0: the three assumptions that carry over, where each one breaks, and the migration order that surfaces failures while they are still cheap. A 0.x to 1.0 bump isn't a version increment — it's a promise about API stability. When a framework owns your routing, data loading, and mutation story all at once, that promise is what tells you it's safe to bet a production app on it. It also means the migration guides are worth reading carefully: the mental model is supposed to be frozen now, even while the ecosystem around it keeps moving.
Three assumptions React teams carry over from Next.js and Remix
Coming from Next.js or Remix, you bring three habits that are load-bearing there and quietly wrong here:
- Routing is convention. Directories and filenames mean what they mean; you never think of a route tree as an artifact you can inspect.
- Loaders are server-only. In Remix,
loadercode never reaches the browser, so people import database clients inside them without thinking twice. - Server actions are the mutation primitive.
"use server"plusrevalidatePathis the reflex.
All three break — and they break in ways that still produce a working demo and a broken production app.
My position: port the route tree first, the data layer second
And assume every loader you write ships to the browser until you can prove otherwise. That order isn't aesthetic: the route tree is where type errors surface, the data layer is where security and cost mistakes surface. Fixing types is cheap. Finding a DATABASE_URL reference in a client chunk after launch is not.
What the 1.0 Announcement Actually Confirms
What the release summary says
The report I'm working from — dated 2026-10-18 — says TanStack Start hit 1.0 stable and describes it as a full-stack React framework with routing and server-side capabilities. That's the confirmed core: a stable release, a full-stack framing, routing plus server-side work in scope. It's a release summary, not an API reference.
Confirmed versus inferred
The announcement doesn't enumerate every adapter, deployment target, or API signature. Anything below that names a method or option comes from the public TanStack Router and TanStack Start docs, plus my own time on pre-1.0 versions — and it needs a re-check against the exact version you install. When I say "the validator method", I mean whatever your installed version exposes; that name has moved between releases, which is precisely why you pin.
Pin the version before you trust any snippet
Mine included:
npm view @tanstack/react-start version
npm view @tanstack/react-router version
Write both numbers into your migration notes. If a snippet below doesn't compile, my first suspicion is a Router/Start version skew, not a typo.
Pre-1.0 TanStack Start code is everywhere in blog posts and LLM output. Much of it uses APIs that were renamed or removed. Treat any snippet without a version pin as untested.
Assumption Break 1 — Routing Is Codegen, Not Just Convention
Layouts are route components, not directory semantics
Next.js layout.tsx and Remix _layout don't map one-to-one. A layout here is a route component that renders an <Outlet />; a pathless grouping segment (_authed) changes which routes share a parent, not the URL. Port your directory structure mechanically and you get URLs that look right with guards attached to the wrong subtree.
The route tree is a generated artifact
This is the first real friction. The plugin and the CLI generate routeTree.gen.ts from your file layout:
// vite.config.ts — illustrative; imports omitted, confirm plugin paths for your pinned version
export default defineConfig({
plugins: [tanstackStart(), react()],
});
npx tsr generate # writes routeTree.gen.ts from the file tree
The consequence people underestimate: a stale generated tree throws type errors in files you never touched. Route.useParams() returns unknown, Link rejects a to that clearly exists, and you burn twenty minutes in the wrong file. When the errors stop making sense, regenerate before you debug.
Typed search params replace the string bag
useSearchParams() hands you strings and hope. Here you declare a schema, and the parse-failure mode becomes something you chose:
// src/routes/posts.index.tsx — imports omitted: createFileRoute, zodValidator, z
const searchSchema = z.object({
page: z.number().int().min(1).default(1).catch(1),
tag: z.string().optional(),
});
export const Route = createFileRoute("/posts/")({
validateSearch: zodValidator(searchSchema),
loaderDeps: ({ search }) => ({ page: search.page, tag: search.tag }),
loader: ({ deps }) => listPosts(deps),
});
The migration cost is real: every searchParams.get('page') turns into a schema field with a default and a decision about garbage input. .catch(1) is that decision — clamp to page one instead of throwing. Multiply that by every filter, sort, and pagination param in the app and you're looking at a day or two of unglamorous work that deletes a whole class of runtime bug.
Route IDs and typed navigation
router.navigate({ to: "/posts/$postId", params: { postId } }) catches link rot at compile time. It also breaks the string-composition style that's all over older codebases:
// before
const href = `/posts/${id}?tab=${tab}`;
// after — the URL is a typed object, not a string
router.navigate({ to: "/posts/$postId", params: { postId: id }, search: { tab } });
Every template-literal href becomes a migration item. That's the trade: you give up string concat, you get a compiler that tells you when a route disappears.
Assumption Break 2 — Loaders Are Isomorphic Until You Make Them Server-Only
Loader code can reach the client bundle
This is the one that hurts. In Remix, loader is server-only by construction. Here, a route loader is part of the route module, and route modules are client-reachable. Import your database client at the top of a route file and you've put it in the client graph.
The fix is a boundary, not discipline: privileged work goes in createServerFn (or a module marked server-only via .server.ts / createServerOnlyFn, depending on your version), and the loader calls it.
beforeLoad vs. loader vs. server function
| What you did in Remix | Where it goes now | Ships to browser? |
|---|---|---|
| Auth guard + redirect in a parent loader | beforeLoad on the pathless route | Yes — logic only, no secrets |
loader reading from the DB | loader → createServerFn | The loader ships; the server fn does not |
action writing to the DB | createServerFn({ method: "POST" }) | No |
| Session lookup for context | beforeLoad calling a server fn | Yes, the call site only |
beforeLoad runs early and returns route context, so it's the right place for the redirect:
export const Route = createFileRoute("/_authed")({
beforeLoad: async ({ location }) => {
const session = await getSession(); // a createServerFn
if (!session) throw redirect({ to: "/login", search: { redirect: location.href } });
return { session };
},
});
A reproducible client-bundle leak check
Run this after every loader conversion, not once at the end:
npm run build
## 1. is the module that reads secrets reachable from the client?
grep -rIl 'DATABASE_URL' dist/client || echo "clean: no server-env reference in client output"
## 2. did an actual value get inlined?
grep -rIl 'postgres://' dist/client || echo "clean: no connection string in client output"
Two different outcomes matter. A name hit means the module is in the client graph — check the dist/client paths in your build output, since the exact directory depends on your config:
dist/client/assets/posts.index-8f2c1a.js
A value hit means real exposure, and it's a release blocker. The clean case should print:
clean: no server-env reference in client output
clean: no connection string in client output
You're getting the check and the two outcome shapes, not a transcript from a 1.0 build — run it and trust your own output over my recollection.
Caching: you own freshness now
There's no implicit fetch memoization contract like the App Router has. Loader re-execution is governed by router settings: staleTime per route, defaultPreload, defaultPreloadStaleTime. Port loaders without touching those and expect more re-fetches than the Next.js version of the page, because nothing is deduped for you. State the settings you chose, then measure — a number without the config beside it isn't a result.
Assumption Break 3 — Server Functions Are Not Server Actions
The validator is the contract
createServerFn replaces both "use server" actions and Remix form actions. The validator isn't decoration: an unvalidated server function is an unauthenticated endpoint that happens to be called from your own UI.
// src/lib/posts.ts — imports omitted: createServerFn, z, auth, removePost
export const deletePost = createServerFn({ method: "POST" })
.inputValidator(z.object({ postId: z.string().uuid() })) // name varies by version
.handler(async ({ data }) => {
const session = await auth(); // authorization first, always
if (!session?.user) throw new Error("unauthorized");
// ownership check, then the write
return removePost(data.postId, session.user.id);
});
Invalidation replaces revalidation
router.invalidate() and route-level reload replace revalidatePath / revalidateTag. The trap is breadth: invalidating the router re-runs loaders you didn't mean to pay for. Invalidate the narrowest scope you can, and check the request log to confirm unrelated loaders didn't fire.
Hiding the button is not a control
Same as everywhere, but easy to forget mid-port because the new call sites look safe. The handler checks authorization on line one, regardless of what the UI renders.
Assumption Break 4 — Deployment Is a Vite Build, Not a Platform Contract
You now own the server bundle target, env var plumbing, and streaming setup that Vercel or a Remix adapter used to absorb. So re-verify three things: the shape of the build output, where static assets are served from, and whether any part of your code assumes a Node runtime when your host hands you an edge one (or the reverse).
Straight answer: the source for this post doesn't enumerate supported adapters, so I'm not going to claim any specific host is first-class today. Mark adapter support as needing confirmation against the deployment docs for whatever version you pinned.
A Migration Order That Fails Cheaply
- Freeze behavior. Characterization tests on existing URLs and payloads — status codes, key response fields, redirect targets.
- Port the route tree with no data changes. Every old URL must resolve. No data layer yet.
- Move loaders one route at a time, running both greps after each. A hit stops the port.
- Convert mutations last, validator-first, auth-check-first.
| Stop signal | Why it blocks | Fix before continuing |
|---|---|---|
searchParams.get(...) in a component | Untyped input path | validateSearch + Route.useSearch() |
Loader imports db or reads process.env.X | Client-graph exposure risk | createServerFn or .server module |
| Server function with no validator | Public endpoint | Add schema, then re-grep |
| Handler without an auth check | Authorization gap | await auth() as line one |
Template-literal href | Silent link rot | <Link to params> or router.navigate |
Where I Would Wait Before Migrating to TanStack Start
Concrete criteria, not vibes: ecosystem packages that assume App Router caching semantics and will misbehave silently; a team with nobody who's debugged a generated route tree (the stale-tree error is the usual onboarding tax); and whether your host is actually first-class today rather than "should work".
Where I land: for a new full-stack React app I'd start here — typed routing and an explicit server boundary are worth more than the ecosystem head start. For a large App Router app mid-quarter, I wouldn't migrate. The route tree port is mechanical; the data layer port is where the schedule dies.
Conclusion — Migrate the Mental Model Before the Files
Two rules prevent most of the bugs in this migration: nothing in a loader is private until it's a server function, and nothing in a server function is authorized until the handler checks it.
Verify against primary sources rather than my recollection — the TanStack Start and TanStack Router docs for your pinned version, and the 1.0 release notes for what the team actually committed to.
Further Reading
- TanStack Start documentation — framework overview, server functions, and hosting notes
- TanStack Router documentation — route trees,
validateSearch,beforeLoad, loader caching options - TanStack Start 1.0 Stable: What Changed — the release summary this post responds to (dated 2026-10-18)
- Zod documentation — the schema layer used for search param and server function validation above


