Une skill Claude Code pour migrer Next.js vers TanStack Start en autonome

Une skill Claude Code pour migrer Next.js vers TanStack Start en autonome

·17 min de lecture·Mis à jour le 23 mai 2026
Avertissement

Cette skill bouffe un projet entier. Elle déplace des fichiers, désinstalle Next.js, réécrit les routes, vire middleware.ts. Bref, elle saccage. C'est fait pour ça : POC, applis de test, projets versionnés où une migration unattended est acceptable. Une branche dédiée et un commit par palier, oui. Mais ne la lancez pas sur de la prod sans une vraie sauvegarde. Sérieusement.

Le déclic

Vendredi midi. Je cherche autre chose et je tombe sur le post d'Inngest qui raconte leur migration Next.js vers TanStack Start. Je lis en diagonale au début, puis je m'arrête. Trois trucs me sautent aux yeux :

  • Le modèle d'exécution est inversé. Tout est isomorphique par défaut, là où les Server Components de Next.js sont serveur par défaut. Pas de "use server", pas de "use client". Une seule directive : createServerFn pour ce qui doit rester côté serveur. Plus simple à raisonner, en théorie.
  • Les types sont entièrement inférés par le router. Params, search, loader data, context : tout. Pas une annotation manuelle, pas un any. La promesse est jolie.
  • Build sur Vite + Nitro, runtime ouvert. Fini le couplage à Vercel pour avoir accès à certaines features.

Je prends un de mes POC Next.js App Router pas trop gros (environ 40 routes, deux trois server actions, du Tailwind, des API routes) et je me dis "allez, je migre ça à la main, ça doit prendre une après-midi".

Trois jours plus tard, je n'ai pas fini. Et pire : chaque route que je débloque me fait douter d'avoir bien fait les précédentes. Tu fixes une erreur TypeScript dans posts/$slug.tsx, et ça t'éclaire sur le fait que ton index.tsx est probablement faux aussi. C'est le genre de chantier qui te pourrit le moral parce que tu sais que t'auras à y revenir.

Le problème, c'est pas que la cible est compliquée. C'est que la migration est mécanique : on convertit src/app/page.tsx en src/routes/index.tsx, on traduit chaque <Link> avec template literal en sa version to=... params=..., etc. Mais le volume tue. Les erreurs TypeScript qui tombent en cascade à chaque modif rendent le diagnostic pénible. Tu corriges une route, trois autres explosent.

C'est exactement le profil de travail à déléguer à une skill autonome. Lundi matin, je me suis posé pour la faire.

Le cahier des charges

Une commande, /migrate-nextjs-to-tanstack, qui prend un projet Next.js et le crache propre en TanStack Start. Sans aucune pause utilisateur. Avec ces contraintes :

  • Unattended. Aucune question intermédiaire. La skill doit pouvoir tourner via /goal pendant que je fais autre chose (genre, pendant que je lave la vaisselle, idéalement).
  • Réversible. Branche dédiée, commit à chaque palier. Si ça casse en chemin, git reset --hard origin/main ramène tout.
  • Vérifiable. La fin de la migration produit dans le transcript la sortie de 5 commandes mécaniques qui prouvent que c'est bon. Pas de "ça a l'air de marcher", des exit 0 ou rien.
  • TanStack Intent strict. Pas de "use server", pas de "use client", pas de cast TypeScript pour patcher une erreur. Les types s'infèrent ou la migration plante.

Et surtout, la règle d'or : la skill ne doit jamais demander à l'utilisateur. Si elle tombe sur un cas non couvert (server component avec streaming, parallel route, unstable_cache...), elle applique une fallback strategy documentée et continue. Elle logge dans un MIGRATION_NOTES.md à la racine pour audit posterieur, mais elle ne s'arrête pas. Jamais.

Les 5 checkpoints qui prouvent la migration

C'est la partie qui m'a pris le plus de temps à designer. Sans preuve mécanique, "j'ai fini" ne veut rien dire. Quand la skill annonce qu'elle a terminé, elle exécute ces 5 commandes, et leur sortie tombe dans le transcript :

# 1. Plus aucun import next/*
grep -r "from ['\"]next/" src/ ; grep -r "from 'next'" src/
# attendu : vide

# 2. Plus aucune directive use server / use client
grep -rE "['\"]use (server|client)['\"]" src/
# attendu : vide

# 3. TypeScript clean
npx tsc --noEmit
# attendu : exit 0

# 4. Build Vite clean
npx vite build
# attendu : exit 0

# 5. Smoke test SSR runtime
npx vite dev --port 3000 &
sleep 8
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" http://localhost:3000/
# attendu : HTTP 200

Le 5e a sauvé la skill plus d'une fois. vite build peut passer en vert pendant que l'app crash au runtime sur une route mal générée, une env var manquante, ou une erreur d'hydratation. J'en ai eu deux comme ça avant d'ajouter ce smoke test. Build vert, app cassée. Lancer un vrai serveur, attendre 8 secondes, faire un GET sur / : c'est primitif mais ça prouve que l'app répond au moins une page. Le minimum syndical.

Ces 5 commandes constituent un contrat vérifiable depuis le transcript. Un évaluateur (humain ou /goal) peut juger en lisant uniquement la sortie. C'est ce qui change tout pour l'autonomie : sans ça, on reste à se demander "est-ce que c'est vraiment fini ?".

L'architecture en 5 phases

Chaque phase commit dans git. Si Phase 3 explose, on a les deux premières propres en historique. C'est moche d'avoir ce filet de sécurité, mais quand t'as déjà perdu 2h à comprendre qu'une migration "presque finie" avait juste corrompu trois fichiers loin du diff visible, tu apprends.

Phase 0 : audit silencieux

On commence par lister toutes les routes Next.js et tous les fichiers qui contiennent "use server" ou "use client". Ça reste en mémoire de la conversation, ça permet de prévoir l'ordre d'attaque.

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

Si la branche actuelle est main/master ou si le working tree est sale, la skill crée d'abord refactor/tanstack-migration et commit l'état initial. Pas de migration sur main. Jamais. (Oui je sais, ça paraît évident. Mais devinez ce que j'ai fait la première fois.)

Phase 1 : scaffolding

C'est la phase où on prépare le terrain. Trois actions critiques :

  1. Installer les deps TanStack (détection du package manager via le lockfile : bun.lockbbun add, pnpm-lock.yamlpnpm add, etc.).

  2. Créer vite.config.ts avec l'ordre des plugins critique :

    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(), // OBLIGATOIRE: APRÈS tanstackStart()
        tailwindcss(),
      ],
    })
    

    Le react() après tanstackStart(). Si t'inverses, le HMR est cassé, les routes ne sont pas détectées, et tu passes une demi-heure à chercher pourquoi rien ne marche alors que la config a l'air OK. J'ai inversé l'ordre deux fois. Deux. Maintenant la skill le fait toujours dans le bon sens.

  3. git mv src/app src/routes. La convention TanStack Start, c'est src/routes/ par défaut. On préserve l'historique git du déplacement, on bouge tout d'un coup, puis le reste de la migration travaille sur src/routes/. Le routeTree.gen.ts sera généré automatiquement au premier vite dev.

À ce stade, le projet est dans un état schizophrène : la config est TanStack, mais le contenu des routes est encore en Next.js. Rien ne build, rien ne tourne. C'est normal. On passe à la suite.

Phase 2 : migration par familles

Le cœur de la skill. On migre du plus simple au plus complexe, par familles :

1. Routes statiques        (page.tsx sans params, sans server action)
2. Routes dynamiques       ([slug]/page.tsx)
3. Routes catch-all        ([...slug]/page.tsx)
4. API routes              (route.ts)
5. Routes avec "use server" (server actions)
6. Pathless layouts        ((marketing), (auth), ...)
7. Conventions spéciales   (loading.tsx, error.tsx, not-found.tsx)

L'ordre n'est pas négociable. Les statiques sont trivialement convertibles, ça permet de valider le scaffolding avant de toucher au compliqué. Les API routes et server actions viennent en milieu de course, quand la config Vite est validée mais avant les conventions spéciales qui touchent au root.

Pour chaque famille, la skill suit un cycle court :

  1. Lister les routes membres.
  2. Appliquer le tableau de conversion (un fichier CONVERSION_TABLE.md séparé contient les patterns exhaustifs).
  3. Après chaque route migrée, npx tsc --noEmit silencieux pour détecter les régressions locales.
  4. Après la famille entière, grep pour vérifier qu'aucun import next/* ne traîne dans les fichiers migrés.
  5. Commit atomique : refactor(routing): migrate <famille> to TanStack Start.

Pas de validation utilisateur entre familles. On enchaîne.

Au début, j'avais essayé sans cet ordre. La skill migrait tout en parallèle, et chaque erreur TypeScript me bouffait 5 minutes parce que je savais pas si elle venait d'un type cassé dans une famille ou d'un import non encore migré dans une autre. Galère. Avec l'ordre strict + checkpoints inter-familles, les erreurs sont localisées : si la famille N casse, c'est à cause de la famille N. Point. Tu sais où chercher.

Phase 3 : composants partagés

Une fois les routes migrées, il reste les composants src/components/ qui importent encore du Next.js. C'est là que se concentrent les remplacements de primitives :

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"           → supprimer (TanStack est isomorphique)

Le piège n°1 sur next/link : les template literals. Du Next.js :

<Link href={`/posts/${id}`}>

Du TanStack, on doit faire :

<Link to="/posts/$postId" params={{ postId: id }} />

Pas de template literal dans to. La skill grep toutes les occurrences et les convertit cas par cas. Pénible mais déterministe.

Le piège n°2 sur "use server" : tout n'est pas une server action. Certains fichiers ont la directive juste parce qu'ils utilisent une dépendance Node.js. Dans ce cas, on wrappe dans createServerFn, on ne migre pas en composant standard. La skill détecte le pattern (accès DB, secrets, fs, env privées) pour décider. C'est imparfait, mais ça couvre 90% des cas.

À la fin de Phase 3, second checkpoint mécanique :

grep -rE "['\"]use (server|client)['\"]" src/ && echo "REMAINING DIRECTIVES" || echo "OK"
grep -r "from ['\"]next/" src/ && echo "REMAINING NEXT IMPORTS" || echo "OK"

Les deux doivent dire OK. Sinon, la skill itère sur les fichiers restants jusqu'à ce que ce soit le cas.

Phase 4 : cleanup final

La phase la plus rapide mais la plus critique. On désinstalle Next.js, on vire les fichiers résiduels, et on exécute les 5 checkpoints décrits plus haut.

<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

Puis les 5 commandes en séquence, chacune précédée d'un echo "=== CHECKPOINT: ... ===" pour que la sortie soit lisible et parseable.

Les zones grises : fallback plutôt que question

C'est la partie où une skill normale demanderait à l'utilisateur. Mais une skill autonome n'a pas le droit. Donc on encode des fallback strategies pour les cas non triviaux :

Cas Next.jsFallback strategy
Server Component avec streamingMigration en composant standard + Route.loader avec Suspense côté client
after() de next/serversetTimeout(() => fn(), 0) dans server fn (best-effort)
Pathless layout (marketing)Conserver le dossier + fichier (marketing).tsx avec layout via createFileRoute
not-found.tsxnotFoundComponent dans createRootRoute
middleware.ts NextLogique migrée dans beforeLoad ou createServerFn middleware
ISR / revalidate / unstable_cachePas d'équivalent direct → Route.staleTime + Route.gcTime
Parallel routes (@modal)Conversion en route simple + state global
Intercepting routes ((.)photo/[id])Conversion en route simple

Chaque fallback est documentée dans un MIGRATION_NOTES.md créé à la racine du projet. L'utilisateur peut lire après coup ce qui a été approximé, et décider s'il veut affiner manuellement. Mais la skill ne s'arrête jamais pour demander.

C'est un compromis assumé. Certains de ces fallbacks sont des downgrades fonctionnels (streaming vers loader+Suspense est moins fluide visuellement, ISR vers staleTime n'a pas la même sémantique). Mais pour un POC ou une appli de test, c'est acceptable. Et c'est toujours mieux qu'une migration interrompue à 60% qui te laisse devant un projet ni Next.js ni TanStack.

Le tableau de conversion comme progressive disclosure

SKILL.md fait ~330 lignes. Tout y mettre serait l'enterrer. Je l'ai éclaté en deux fichiers, façon progressive disclosure :

migrate-nextjs-to-tanstack/
├── SKILL.md             (orchestrateur, phases, fallbacks)
└── CONVERSION_TABLE.md  (patterns exhaustifs Next.js → TanStack, ~515 lignes)

La skill est publiée sur GitHub : version FR, version EN. Pour l'installer, cloner le dossier dans ~/.claude/skills/migrate-nextjs-to-tanstack/, relancer Claude Code, et la commande /migrate-nextjs-to-tanstack devient disponible.

CONVERSION_TABLE.md n'est chargé que pendant Phase 2 et Phase 3, quand la skill migre du code. Le reste du temps, ces 515 lignes ne consomment aucun token. Multiplié par toutes les invocations futures, l'économie est réelle.

Le tableau couvre 18 sections : root layout, page route statique, dynamic route avec loader, catch-all, search params, server action, API route, links, navigation programmatique, image, font, metadata, middleware (avec 2 options : beforeLoad simple ou createMiddleware complexe), loading/error/notFound, vite config, router.tsx, package.json scripts.

Pour chaque section : code Next.js, code TanStack Start équivalent, et les pièges en commentaire. La skill copie les patterns à l'identique, sans improvisation. Si la conversion est mécanique, autant la rendre déterministe.

L'usage avec /goal

La skill est conçue pour /goal. Concrètement :

/goal migre ce projet Next.js vers TanStack Start, vérifie que les 5 checkpoints
finaux passent en vert, et restitue un rapport

/goal invoque /migrate-nextjs-to-tanstack en boucle si besoin, lit la sortie des 5 checkpoints à la fin, et décide objectivement si la migration est complète ou s'il faut une itération supplémentaire. Aucune intervention humaine pendant le run.

Pour ce mode de fonctionnement, deux propriétés sont indispensables :

  • Aucune AskUserQuestion dans le workflow (sinon /goal reste bloqué indéfiniment)
  • Sortie verifiable des checkpoints au format texte parseable (PASS: / FAIL: explicites)

C'est ce qui m'a fait écrire les 5 commandes finales en echo "=== CHECKPOINT: X ===" && cmd && echo PASS || echo FAIL. Ça rend la sortie scannable au regex, et /goal peut juger sans ambiguïté. La première version utilisait des marqueurs moins explicites, et /goal se trompait une fois sur trois sur l'état final.

Ce que j'ai appris en faisant ça

TanStack Intent est plus radical que Next.js sur la pureté du modèle. Pas de directive, pas d'annotation manuelle, types entièrement inférés. C'est beau sur le papier, mais ça implique qu'une migration "à moitié faite" ne compile pas. Soit tout est isomorphique avec createServerFn pour le serveur, soit rien ne marche. Pas de zone grise. C'est sport au début, mais une fois habitué, on comprend pourquoi : un seul modèle mental à charger, pas deux.

L'ordre de migration importe énormément. En migrant par familles du plus simple au plus complexe, les erreurs TypeScript restent localisées. En migrant en parallèle (ce que j'avais essayé d'abord, par bête optimisme), les cascades d'erreurs noient le signal. La règle "valider la config Vite avant de toucher au compliqué" m'a fait gagner 60% du temps de debug sur la version 2.

Les checkpoints mécaniques transforment l'évaluation. "La migration est finie" est subjectif. "Les 5 grep/tsc/build/curl passent en vert" est vérifiable. Ce glissement-là, c'est ce qui rend une skill compatible /goal. Sans ça, n'importe quel évaluateur reste suspicieux et redemande des vérifications. Avec ça, c'est binaire.

Les fallback strategies évitent les blocages. Sur ma première version, la skill demandait à l'utilisateur dès qu'elle tombait sur un cas non trivial (streaming, parallel route...). Résultat : /goal se bloquait, et l'autonomie disparaissait au premier cas exotique. Encoder un fallback "best-effort + log dans MIGRATION_NOTES.md" pour chaque cas connu, même si le fallback est imparfait, c'est ce qui rend la skill vraiment unattended.

Le smoke test vite dev + curl est non négociable. vite build peut passer en vert sur une app qui crash au runtime. Lancer un serveur, attendre 8 secondes, faire un GET sur /, c'est primitif, mais c'est ce qui détecte les erreurs d'hydratation, les env vars manquantes, les routeTree mal générés. Sans ça, j'ai eu 2 migrations "réussies" sur 5 où l'app ne servait même pas la home page. Build vert, prod blanche. Et là tu te sens con.

Les limites

C'est un outil pour des migrations acceptables en best-effort. Pas pour de la prod critique. À utiliser sur :

  • POC qu'on veut faire passer à TanStack Start pour évaluer la techno
  • Applis de test, projets perso, side-projects
  • Projets avec coverage de tests minimaliste (la skill ne génère pas de tests)

À éviter sur :

  • Production sous trafic, où les downgrades sur ISR ou parallel routes peuvent casser des features
  • Projets avec des conventions Next.js avancées (intercepting routes, server actions complexes avec optimistic UI, middlewares custom multiples)
  • Codebases avec plus de 100 routes, où le tsc --noEmit final peut prendre plusieurs minutes par itération

Et bien sûr, c'est une photographie de TanStack Start à mai 2026. L'API est encore jeune. Si TanStack change createServerFn, head(), ou la convention de fichiers, la skill devient potentiellement obsolète. C'est le deal de toute skill qui encode des patterns API. Je la mettrai à jour quand il faudra.

C'est devenu mon outil par défaut quand je veux évaluer la migration d'un projet Next.js. 5 minutes de skill autonome remplacent 3 jours de migration manuelle, et le rapport final me dit où sont les zones grises à reprendre. Le temps gagné, je le passe à comprendre TanStack Intent au lieu de grep des "use server". Bon échange.

PartagerLinkedInXBluesky

Articles similaires