Loading...
Loading...
Autonomous site migration from any legacy stack to modern Next.js. Visual diffing, best practices enforcement, live progress on localhost.
npx skill4agent add alan-ws/migent migrate-to-nextjsnpx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-best-practices --yes
npx skills add https://github.com/vercel-labs/next-skills --skill next-best-practices --yes
npx skills add https://github.com/vercel-labs/next-skills --skill next-cache-components --yes
npx skills add https://github.com/vercel-labs/agent-skills --skill web-design-guidelines --yesnpm install -g migentmigent --versionpackage.jsoncomposer.jsonGemfileindex.htmlindex.php<legacy-name>-nextls -la <legacy-directory>
curl -s -o /dev/null -w "%{http_code}" http://localhost:<port>/200/next-best-practices
/vercel-react-best-practices
/web-design-guidelinesbunx create-next-app@latest <project-name> \
--typescript \
--tailwind \
--app \
--src-dir \
--import-alias "@/*" \
--use-bun \
--yesbunnpmbunxnpxcd <project-name>
bun add -D @biomejs/biomebiome.json{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": { "recommended": true }
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
}
}workspace/ ← config goes HERE
├── legacy-site/
├── my-next-app/
└── .mcp.json.mcp.json{
"mcpServers": {
"migent": {
"command": "npx",
"args": ["-y", "migent", "mcp"]
}
}
}bun run devir_capture(port: 3000, route: "/")elementCount > 0mkdir -p <next-project>/public
cp -r <legacy-directory>/images/* <next-project>/public/images/ 2>/dev/null || true
cp -r <legacy-directory>/fonts/* <next-project>/public/fonts/ 2>/dev/null || true
cp -r <legacy-directory>/assets/* <next-project>/public/assets/ 2>/dev/null || truesitemap.xmlir_captureir_capture# Call all in one message — they run concurrently:
ir_capture(port: <legacy-port>, route: "/")
ir_capture(port: <legacy-port>, route: "/about")
ir_capture(port: <legacy-port>, route: "/contact")
# ... all discovered routesmigration.json{
"legacy": {
"directory": "./legacy-site",
"port": 8000,
"framework": "php",
"routes": ["/", "/about", "/contact"]
},
"captures": {
"/": { "elementCount": 150, "animationCount": 12 },
"/about": { "elementCount": 89, "animationCount": 3 }
},
"next": {
"directory": "./my-next-app",
"port": 3000
},
"routeStatus": {
"/": "pending",
"/about": "pending",
"/contact": "pending"
},
"progress": {},
"skippedIssues": []
}# Find jQuery
grep -r "jquery\|jQuery\|\\\$(" <legacy-directory> --include="*.js" --include="*.html" --include="*.php"
# Find inline handlers
grep -r "onclick=\|onsubmit=\|onchange=" <legacy-directory> --include="*.html" --include="*.php"migration.jsonlegacy.javascriptir_captureir_captureredirects//en//en/about/fr/aboutsrc/middleware.tsnext-intlir_captureinternalLinksir_capturecd <next-project>
bun add framer-motion/dangerouslySetInnerHTMLonclick="..."<script>class=className=classNameonClick={handler}next/image'use client'next/font/src/app/layout.tsxsrc/components/migration.jsoncomponents/migration.jsonir_inspect(selector: "...", site: "legacy")src/app/<route>/page.tsxsrc/components/# Anti-patterns (ALL MUST RETURN no results)
grep -r "dangerouslySetInnerHTML" <next-project>/src/
grep -r 'onclick="' <next-project>/src/
grep -r 'class="' <next-project>/src/ --include="*.tsx" --include="*.jsx"
grep -r 'style={{' <next-project>/src/ --include="*.tsx" --include="*.jsx"
grep -r "from ['\"]jquery['\"]" <next-project>/src/ir_start(legacyPort: <legacy-port>, nextPort: 3000, legacyRoute: "<route>", nextRoute: "<route>")
→ { status: "watching", match: {...}, totalIssues: N, firstIssue: {...} }result = ir_next()
IF result.clsBlocked:
- CLS score is above 0.1 — ir_next REFUSES to serve other issues
- Read result.cls.topShifters to identify which elements shifted
- Fix using result.suggestedFixes:
1. Font shift → next/font with display: "swap", adjustFontFallback: true
2. Image shift → next/image with explicit width + height
3. Dynamic content → min-height or skeleton placeholders
4. Embeds → fixed aspect-ratio container
- Save file → watch recaptures → call ir_next again
- Repeat until clsBlocked is gone
IF result.regressionBlocked:
- New issues were introduced — fix the regression first
- Save file → watch recaptures → call ir_next again
IF result.issue exists:
- Read issue details (selector, styles, position)
- Fix the specific issue
- Save file → wait for rebuild → call ir_next again
- After 3 failed attempts on the same issue: ir_next(skip: true)
- Document skipped issue in migration.json under skippedIssues
IF result.complete or match >= 95%:
- Proceed to 3.4ir_nextir_stop()routeStatus"validated"migration.jsonrouteStatus"failed"MIGRATION_REPORT.mdir_stop()cd <next-project>
bunx shadcn@latest init -y.mcp.json{
"mcpServers": {
"migent": {
"command": "npx",
"args": ["-y", "migent", "mcp"]
},
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
}
}
}ir_captureuiPatterns.shadcnComponentsNeeded# Example: if uiPatterns shows Button, Dialog, Table, NavigationMenu
bunx shadcn@latest add button dialog table navigation-menu -y<button><Button><input><Input><table><Table><dialog>.modal<Dialog>@font-facenext/fontir_startir_start(legacyPort: <legacy-port>, nextPort: 3000, legacyRoute: "<route>", nextRoute: "<route>")# shadcn enforcement — raw HTML elements should be replaced
grep -rn '<button' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
grep -rn '<input' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
grep -rn '<textarea' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
grep -rn '<select' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
grep -rn '<table' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
grep -rn '<dialog' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
# Font enforcement — no raw @font-face
grep -r "@font-face" <next-project>/src/
# Verify shadcn IS being used
grep -rn "from ['\"]@/components/ui/" <next-project>/src/ --include="*.tsx"
# Legacy CSS class names should be converted to Tailwind
grep -rE 'className="[^"]*[a-z]+_[a-z]+' <next-project>/src/ --include="*.tsx"migration.jsonrouteStatus| Status | Meaning |
|---|---|
| Not yet migrated |
| Passed visual validation (match >= 95%) |
| Below 95% after exhausting fixes, needs human review |
migration.json/migrationvalidatedpendingfailedir_inspect(selector: "...", site: "legacy")rgb(196, 30, 58) → bg-[#c41e3a] or bg-red-600 (if close match)
rgb(255, 255, 255) → bg-white
rgb(0, 0, 0) → bg-black
rgba(0,0,0,0.5) → bg-black/50padding: "16px" → p-4
padding: "15px 20px" → py-[15px] px-5
margin: "0 auto" → mx-auto
margin: "24px 0 0 0" → mt-6fontSize: "14px" → text-sm
fontSize: "18px" → text-lg
fontSize: "32px" → text-3xl
fontWeight: "700" → font-bold
fontWeight: "600" → font-semibold
lineHeight: "1.5" → leading-normal
textAlign: "center" → text-centerdisplay: "flex" → flex
display: "grid" → grid
flexDirection: "column" → flex-col
justifyContent: "center" → justify-center
alignItems: "center" → items-center
gap: "16px" → gap-4width: "100%" → w-full
maxWidth: "1280px" → max-w-7xl
height: "auto" → h-auto
minHeight: "100vh" → min-h-screenposition: "absolute" → absolute
position: "relative" → relative
position: "fixed" → fixed
top: "0px" → top-0
left: "50%" → left-1/2borderRadius: "8px" → rounded-lg
borderRadius: "9999px" → rounded-full
borderWidth: "1px" → border
borderColor: "rgb(229,231,235)" → border-gray-200fontStyle: "italic" → italic
fontStyle: "normal" → not-italictextTransform: "uppercase" → uppercase
textTransform: "lowercase" → lowercase
textTransform: "capitalize" → capitalize
textTransform: "none" → normal-casetextDecoration: "underline" → underline
textDecoration: "line-through" → line-through
textDecoration: "none" → no-underlineoverflow: "hidden" → overflow-hidden
overflow: "auto" → overflow-auto
overflow: "scroll" → overflow-scroll
overflowX: "auto" → overflow-x-auto
overflowY: "hidden" → overflow-y-hiddengridTemplateColumns: "repeat(3, 1fr)" → grid-cols-3
gridTemplateColumns: "repeat(4, minmax(0, 1fr))" → grid-cols-4
gridTemplateColumns: "200px 1fr" → grid-cols-[200px_1fr]transform: "translateX(-50%)" → -translate-x-1/2
transform: "rotate(45deg)" → rotate-45
transform: "scale(1.1)" → scale-110opacity: "0.5" → opacity-50
boxShadow: "0 1px 3px rgba(0,0,0,0.1)" → shadow-sm
boxShadow: "0 10px 15px rgba(0,0,0,0.1)" → shadow-lgpadding: "13px" → p-[13px]
backgroundColor: "#c41e3a" → bg-[#c41e3a]
fontSize: "17px" → text-[17px]
maxWidth: "1140px" → max-w-[1140px]animationsir_capture// Captured: { name: "fadeInUp", duration: "0.6s", timingFunction: "ease-out" }
import { motion } from 'framer-motion';
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>/* Add to globals.css — copy the captured keyframes rule */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}<div className="animate-[fadeInUp_0.6s_ease-out]">// Captured: { property: "background-color", duration: "0.2s", timingFunction: "ease" }
<button className="transition-colors duration-200 ease-in-out hover:bg-red-700">// Captured: jQueryAnimations: [".fadeIn(300)"]
import { AnimatePresence, motion } from 'framer-motion';
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
)}
</AnimatePresence>ir_capturefontsnext/font/googleimport { Inter, Roboto } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
weight: ['400', '700'], // from fonts[].weight
style: ['normal', 'italic'], // from fonts[].style
display: 'swap', // from fonts[].display or default to 'swap'
variable: '--font-inter',
});next/font/localimport localFont from 'next/font/local';
const customFont = localFont({
src: [
{ path: './fonts/custom-regular.woff2', weight: '400', style: 'normal' },
{ path: './fonts/custom-bold.woff2', weight: '700', style: 'normal' },
],
variable: '--font-custom',
display: 'swap',
});layout.tsxexport default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${customFont.variable}`}>
<body>{children}</body>
</html>
);
}tailwind.config.tsfontFamily: {
sans: ['var(--font-inter)', ...defaultTheme.fontFamily.sans],
custom: ['var(--font-custom)'],
},ir_capturefonts[].srcpublic/fonts/next/font/local| Legacy HTML | shadcn Component | Import |
|---|---|---|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
// WRONG - raw HTML
<button className="bg-red-600 text-white px-4 py-2 rounded">Submit</button>
// CORRECT - shadcn Button
import { Button } from "@/components/ui/button";
<Button className="bg-red-600 text-white">Submit</Button>// WRONG - raw HTML table
<table><tr><td>Name</td></tr></table>
// CORRECT - shadcn Table
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
<Table>
<TableBody>
<TableRow>
<TableCell>Name</TableCell>
</TableRow>
</TableBody>
</Table><button><input><table><dialog># No raw @font-face in CSS (must use next/font)
grep -r "@font-face" <next-project>/src/
# No raw <button> outside components/ui/ (must use shadcn Button)
grep -rn '<button' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
# No raw <input> outside components/ui/ (must use shadcn Input)
grep -rn '<input' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
# No raw <table> outside components/ui/ (must use shadcn Table)
grep -rn '<table' <next-project>/src/ --include="*.tsx" --include="*.jsx" | grep -v 'components/ui/'
# Verify shadcn components ARE being used
grep -rn "from ['\"]@/components/ui/" <next-project>/src/ --include="*.tsx"ir_capture(port: number, route?: string, width?: number, height?: number)fontsuiPatternsredirectsinternalLinksir_start(legacyPort, nextPort, legacyRoute?, nextRoute?, watchPaths?){ status: "watching", match: {...}, totalIssues: N, firstIssue: {...} }ir_next(skip?: boolean)skip: trueir_inspect(selector: string, site?: "legacy" | "next" | "both")site="legacy""next"site="both"