Migrating from Next.js or Remix to TanStack Start 1.0: Where Existing Assumptions Break

Migrating from Next.js or Remix to TanStack Start 1.0: Where Existing Assumptions Break

pr0h0•
tanstack-startnextjsremixreactmigration
AI Usage (80%)

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:

  1. Routing is convention. Directories and filenames mean what they mean; you never think of a route tree as an artifact you can inspect.
  2. Loaders are server-only. In Remix, loader code never reaches the browser, so people import database clients inside them without thinking twice.
  3. Server actions are the mutation primitive. "use server" plus revalidatePath is 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 RemixWhere it goes nowShips to browser?
Auth guard + redirect in a parent loaderbeforeLoad on the pathless routeYes — logic only, no secrets
loader reading from the DBloader → createServerFnThe loader ships; the server fn does not
action writing to the DBcreateServerFn({ method: "POST" })No
Session lookup for contextbeforeLoad calling a server fnYes, 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

  1. Freeze behavior. Characterization tests on existing URLs and payloads — status codes, key response fields, redirect targets.
  2. Port the route tree with no data changes. Every old URL must resolve. No data layer yet.
  3. Move loaders one route at a time, running both greps after each. A hit stops the port.
  4. Convert mutations last, validator-first, auth-check-first.
Stop signalWhy it blocksFix before continuing
searchParams.get(...) in a componentUntyped input pathvalidateSearch + Route.useSearch()
Loader imports db or reads process.env.XClient-graph exposure riskcreateServerFn or .server module
Server function with no validatorPublic endpointAdd schema, then re-grep
Handler without an auth checkAuthorization gapawait auth() as line one
Template-literal hrefSilent 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

Share this post

More posts

Comments