
A Claude Code Skill to Migrate Next.js to TanStack Start Unattended
This skill chews through an entire project. It moves files around, uninstalls Next.js, rewrites the routes, deletes middleware.ts. Basically: it makes a mess. That's the point: POCs, test apps, versioned projects where an unattended migration is acceptable. Dedicated branch and a commit at each step, yes. But don't run it on prod without a real backup. Seriously.
The trigger
Friday lunch. I'm looking for something else and I land on the Inngest post about their migration from Next.js to TanStack Start. I skim it at first, then I stop. Three things jump out:
- The execution model is inverted. Everything is isomorphic by default, where Next.js Server Components are server by default. No
"use server", no"use client". One directive only:createServerFnfor what must stay on the server. Easier to reason about, in theory. - The types are fully inferred by the router. Params, search, loader data, context: all of it. Not a single manual annotation, not a single
any. The promise is pretty. - Build on Vite + Nitro, open runtime. No more Vercel lock-in for some of the fancy features.
I grab one of my Next.js App Router POCs that's not too big (about 40 routes, a couple server actions, some Tailwind, a few API routes) and I tell myself "right, I'll migrate this by hand, should be an afternoon".
Three days later, I'm not done. And worse: every route I unblock makes me doubt I did the previous ones right. You fix a TypeScript error in posts/$slug.tsx, and it dawns on you that your index.tsx is probably wrong too. The kind of project that wears you down because you know you'll have to come back to it.
The problem isn't that the target is complicated. It's that the migration is mechanical: you convert src/app/page.tsx to src/routes/index.tsx, you translate each <Link> with a template literal into its to=... params=... equivalent, etc. But the volume kills you. The TypeScript errors that cascade with every edit make diagnosis a chore. You fix one route, three others explode.
This is exactly the kind of work to hand off to an autonomous skill. Monday morning, I sat down to build it.
The brief
One command, /migrate-nextjs-to-tanstack, that takes a Next.js project and spits out a clean TanStack Start. No user pause. With these constraints:
- Unattended. No intermediate questions. The skill has to run under
/goalwhile I do something else (ideally, while I'm doing the dishes). - Reversible. Dedicated branch, commit at every step. If something breaks midway,
git reset --hard origin/mainbrings everything back. - Verifiable. The end of the migration drops the output of 5 mechanical commands into the transcript that prove it's done. None of that "it seems to work",
exit 0or nothing. - Strict TanStack Intent. No
"use server", no"use client", no TypeScript casts to patch errors. Types infer cleanly or the migration fails.
And above all, the golden rule: the skill must never ask the user. If it hits an uncovered case (server component with streaming, parallel route, unstable_cache...), it applies a documented fallback strategy and keeps going. It logs into MIGRATION_NOTES.md at the project root for later audit, but it never stops. Ever.
The 5 checkpoints that prove the migration
This is the part that took me longest to design. Without mechanical proof, "I'm done" means nothing. When the skill announces it's finished, it runs these 5 commands, and their output lands in the transcript:
# 1. No more next/* imports
grep -r "from ['\"]next/" src/ ; grep -r "from 'next'" src/
# expected: empty
# 2. No more use server / use client directives
grep -rE "['\"]use (server|client)['\"]" src/
# expected: empty
# 3. TypeScript clean
npx tsc --noEmit
# expected: exit 0
# 4. Vite build clean
npx vite build
# expected: exit 0
# 5. SSR runtime smoke test
npx vite dev --port 3000 &
sleep 8
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" http://localhost:3000/
# expected: HTTP 200
The 5th saved the skill more than once. vite build can pass green while the app crashes at runtime on a badly generated route, a missing env var, or a hydration error. I had two of those before I added this smoke test. Build green, app broken. Launching a real server, waiting 8 seconds, hitting /: primitive but proves the app actually serves at least one page. The bare minimum.
These 5 commands form a contract verifiable from the transcript. An evaluator (human or /goal) can judge by reading the output alone. That's what changes everything for autonomy: without it, you're stuck wondering "is it really done?".
The architecture in 5 phases
Each phase commits to git. If Phase 3 explodes, the first two are clean in history. It's ugly to need that safety net, but once you've spent 2 hours figuring out that an "almost finished" migration had quietly corrupted three files outside the visible diff, you learn.
Phase 0: silent audit
We start by listing all Next.js routes and all files containing "use server" or "use client". It stays in the conversation memory so we can plan the order of attack.
find src/app -type f \( -name "page.tsx" -o -name "layout.tsx" \
-o -name "route.ts" -o -name "route.tsx" -o -name "loading.tsx" \
-o -name "error.tsx" -o -name "not-found.tsx" -o -name "middleware.ts" \) | sort
grep -rE "['\"]use (server|client)['\"]" src/ -l | sort -u
If the current branch is main/master or the working tree is dirty, the skill first creates refactor/tanstack-migration and commits the initial state. No migrations on main. Ever. (Yes, I know, sounds obvious. But guess what I did the first time.)
Phase 1: scaffolding
This is where we prep the ground. Three critical actions:
Install TanStack deps (detect the package manager via the lockfile:
bun.lockb→bun add,pnpm-lock.yaml→pnpm add, etc.).Create
vite.config.tswith the critical plugin order:import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ server: { port: 3000 }, resolve: { tsconfigPaths: true }, plugins: [ tanstackStart({ srcDirectory: 'src' }), react(), // REQUIRED: AFTER tanstackStart() tailwindcss(), ], })react()AFTERtanstackStart(). If you swap them, HMR breaks, routes don't get detected, and you spend half an hour wondering why nothing works while the config looks fine. I got the order wrong twice. Twice. Now the skill always does it right.git mv src/app src/routes. TanStack Start's default convention issrc/routes/. We preserve git history through the move, shift everything at once, then the rest of the migration works onsrc/routes/. TherouteTree.gen.tsgets generated automatically on the firstvite dev.
At this point, the project is in a schizophrenic state: the config is TanStack but the route contents are still Next.js. Nothing builds, nothing runs. That's normal. Moving on.
Phase 2: family-by-family migration
The heart of the skill. We migrate from simplest to most complex, by families:
1. Static routes (page.tsx with no params, no server action)
2. Dynamic routes ([slug]/page.tsx)
3. Catch-all routes ([...slug]/page.tsx)
4. API routes (route.ts)
5. Routes with "use server" (server actions)
6. Pathless layouts ((marketing), (auth), ...)
7. Special conventions (loading.tsx, error.tsx, not-found.tsx)
The order isn't negotiable. Static routes are trivially convertible, which validates the scaffolding before touching anything hard. API routes and server actions come in the middle, once the Vite config is validated but before the special conventions that touch the root.
For each family, the skill runs a short cycle:
- List the family members.
- Apply the conversion table (a separate
CONVERSION_TABLE.mdfile holds the exhaustive patterns). - After each migrated route, a silent
npx tsc --noEmitto catch local regressions. - After the whole family, grep to make sure no
next/*imports linger in the migrated files. - Atomic commit:
refactor(routing): migrate <family> to TanStack Start.
No user validation between families. We just chain them.
I'd tried without this order at first. The skill migrated everything in parallel, and every TypeScript error ate 5 minutes because I didn't know if it came from a busted type in one family or an unmigrated import in another. Painful. With strict order + inter-family checkpoints, errors are localized: if family N breaks, it's because of family N. Period. You know where to look.
Phase 3: shared components
Once the routes are migrated, the src/components/ directory still imports from Next.js. This is where primitive replacements concentrate:
next/link → @tanstack/react-router Link
next/navigation → useNavigate, Route.useParams, Route.useSearch
next/image → @unpic/react
next/font → Fontsource via globals.css
"use server" → createServerFn(...).handler(...)
"use client" → delete (TanStack is isomorphic)
Pitfall #1 on next/link: template literals. In Next.js:
<Link href={`/posts/${id}`}>
In TanStack, you have to write:
<Link to="/posts/$postId" params={{ postId: id }} />
No template literal in to. The skill greps every occurrence and converts case by case. Tedious but deterministic.
Pitfall #2 on "use server": not everything is a server action. Some files carry the directive just because they use a Node.js dependency. In that case, we wrap in createServerFn, we don't migrate to a standard component. The skill detects the pattern (DB access, secrets, fs, private env) to decide. It's imperfect, but covers 90% of cases.
End of Phase 3, second mechanical checkpoint:
grep -rE "['\"]use (server|client)['\"]" src/ && echo "REMAINING DIRECTIVES" || echo "OK"
grep -r "from ['\"]next/" src/ && echo "REMAINING NEXT IMPORTS" || echo "OK"
Both must say OK. Otherwise the skill iterates on the remaining files until they do.
Phase 4: final cleanup
The fastest phase but the most critical. We uninstall Next.js, delete residual files, and run the 5 checkpoints from earlier.
<pm> remove next @next/* eslint-config-next 2>/dev/null || true
rm -f next.config.{js,ts,mjs} next-env.d.ts middleware.ts src/middleware.ts
Then the 5 commands in sequence, each preceded by an echo "=== CHECKPOINT: ... ===" so the output stays readable and parseable.
Gray areas: fallback instead of asking
This is where a normal skill would ask the user. But an autonomous skill isn't allowed. So we encode fallback strategies for the non-trivial cases:
| Next.js case | Fallback strategy |
|---|---|
| Server Component with streaming | Migrate to standard component + Route.loader with client-side Suspense |
after() from next/server | setTimeout(() => fn(), 0) inside a server fn (best-effort) |
Pathless layout (marketing) | Keep the folder + a (marketing).tsx file with a layout via createFileRoute |
not-found.tsx | notFoundComponent in createRootRoute |
middleware.ts Next | Logic migrated into beforeLoad or createServerFn middleware |
ISR / revalidate / unstable_cache | No direct equivalent → Route.staleTime + Route.gcTime |
Parallel routes (@modal) | Convert to a plain route + global state |
Intercepting routes ((.)photo/[id]) | Convert to a plain route |
Each fallback gets documented in a MIGRATION_NOTES.md created at the project root. The user can read afterwards what got approximated, and decide whether to refine manually. But the skill never stops to ask.
It's a deliberate compromise. Some of those fallbacks are functional downgrades (streaming to loader+Suspense is visually less smooth, ISR to staleTime doesn't have the same semantics). But for a POC or a test app, it's acceptable. And it's always better than a migration interrupted at 60% that leaves you with a project that's neither Next.js nor TanStack.
The conversion table as progressive disclosure
SKILL.md is around 330 lines. Putting everything inside it would bury it. I split it into two files, progressive-disclosure style:
migrate-nextjs-to-tanstack/
├── SKILL.md (orchestrator, phases, fallbacks)
└── CONVERSION_TABLE.md (exhaustive Next.js → TanStack patterns, ~515 lines)
The skill is published on GitHub: EN version, FR version. To install, clone the folder into ~/.claude/skills/migrate-nextjs-to-tanstack/, restart Claude Code, and the /migrate-nextjs-to-tanstack command becomes available.
CONVERSION_TABLE.md only loads during Phase 2 and Phase 3, when the skill is actually migrating code. The rest of the time, those 515 lines burn zero tokens. Multiplied across all future invocations, the savings are real.
The table covers 18 sections: root layout, static page route, dynamic route with loader, catch-all, search params, server action, API route, links, programmatic navigation, image, font, metadata, middleware (with 2 options: simple beforeLoad or complex createMiddleware), loading/error/notFound, vite config, router.tsx, package.json scripts.
For each section: Next.js code, equivalent TanStack Start code, and pitfalls in comments. The skill copies the patterns verbatim, no improvisation. If the conversion is mechanical, might as well make it deterministic.
Using it with /goal
The skill is built for /goal. Concretely:
/goal migrate this Next.js project to TanStack Start, verify the 5 final
checkpoints pass green, and return a report
/goal invokes /migrate-nextjs-to-tanstack in a loop if needed, reads the output of the 5 checkpoints at the end, and decides objectively whether the migration is complete or another iteration is needed. No human in the loop during the run.
For this mode to work, two properties are non-negotiable:
- No
AskUserQuestionin the workflow (otherwise/goalblocks indefinitely) - Verifiable output of the checkpoints in parseable text format (explicit
PASS:/FAIL:)
That's what pushed me to write the 5 final commands as echo "=== CHECKPOINT: X ===" && cmd && echo PASS || echo FAIL. It makes the output regex-scannable, and /goal can judge without ambiguity. The first version used less explicit markers, and /goal got the final state wrong one time out of three.
What I learned doing this
TanStack Intent is more radical than Next.js on model purity. No directive, no manual annotation, types fully inferred. Beautiful on paper, but it means a "half done" migration doesn't compile. Either everything is isomorphic with createServerFn for server-side, or nothing works. No gray zone. Rough at first, but once you're used to it, you get why: a single mental model to load, not two.
Migration order matters enormously. Migrating family by family from simplest to most complex keeps TypeScript errors localized. Migrating in parallel (which I tried first, out of dumb optimism) drowns the signal in cascading errors. The rule "validate the Vite config before touching the hard stuff" saved me 60% of debug time on version 2.
Mechanical checkpoints transform evaluation. "The migration is finished" is subjective. "The 5 grep/tsc/build/curl pass green" is verifiable. That shift is what makes a skill /goal-compatible. Without it, any evaluator stays suspicious and asks for more verification. With it, it's binary.
Fallback strategies prevent blockers. In my first version, the skill asked the user whenever it hit a non-trivial case (streaming, parallel route...). Result: /goal blocked, and autonomy went out the window the first time anything exotic showed up. Encoding a "best-effort + log in MIGRATION_NOTES.md" fallback for each known case, even an imperfect fallback, is what makes the skill genuinely unattended.
The vite dev + curl smoke test isn't optional. vite build can pass green on an app that crashes at runtime. Launching a server, waiting 8 seconds, hitting /: primitive, but that's what catches hydration errors, missing env vars, badly generated routeTree. Without it, I had 2 "successful" migrations out of 5 where the app didn't even serve the home page. Build green, prod blank. And you feel stupid.
The limits
This is a tool for best-effort acceptable migrations. Not for critical prod. Use it on:
- POCs you want to push to TanStack Start to evaluate the tech
- Test apps, side projects, personal stuff
- Projects with minimal test coverage (the skill doesn't generate tests)
Don't use it on:
- Production under traffic, where the ISR or parallel route downgrades can break features
- Projects with advanced Next.js conventions (intercepting routes, complex server actions with optimistic UI, multiple custom middlewares)
- Codebases with more than 100 routes, where the final
tsc --noEmitcan take several minutes per iteration
And of course, it's a snapshot of TanStack Start as of May 2026. The API is still young. If TanStack changes createServerFn, head(), or the file convention, the skill becomes potentially obsolete. That's the deal for any skill that encodes API patterns. I'll update it when I have to.
It's become my default tool when I want to evaluate migrating a Next.js project. 5 minutes of autonomous skill replace 3 days of manual migration, and the final report tells me where the gray areas need a closer look. The time saved, I spend on understanding TanStack Intent instead of grepping "use server". Fair trade.
Related articles