migrate-to-nextjs
npx skills add https://github.com/alan-ws/migent --skill migrate-to-nextjs
Agent 安装分布
Skill 文档
Site Migration Skill
STOP. READ THIS ENTIRE DOCUMENT BEFORE DOING ANYTHING.
This skill migrates legacy websites to modern Next.js. You MUST follow every step exactly. Skipping steps will result in failed migrations.
PHASE 1: SETUP
Install all dependencies, ask the user questions, create the Next.js project, and configure MCP. Do not proceed to Phase 2 until everything is verified.
1.1 Install Skills
Run each command and verify it succeeds:
npx 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 --yes
1.2 Install Migent
npm install -g migent
VERIFY: migent --version returns a version number.
1.3 Ask User Questions
-
“Which directory contains your legacy site?”
- Scan workspace for
package.json,composer.json,Gemfile,index.html,index.php - Offer detected options as choices
- Scan workspace for
-
“What port is your legacy site running on?” (or “How do I start it?”)
- Common ports: 3000, 4000, 8000, 8080
-
“What should I name the Next.js project?”
- Suggest:
<legacy-name>-next
- Suggest:
1.4 Validate Legacy Site
ls -la <legacy-directory>
curl -s -o /dev/null -w "%{http_code}" http://localhost:<port>/
MUST PASS: Directory exists AND curl returns 200.
CAPTURE any observed request patterns. Example: Redirect to /en means the site has localisation â include it in the migration plan.
IF VALIDATION FAILS: Return to user with specific error. Do not guess or proceed.
1.5 Load All Skills
Load all skill contexts now. They will be used throughout the migration.
/next-best-practices
/vercel-react-best-practices
/web-design-guidelines
IMPORTANT: Always use the latest Next.js and tailwindcss (check versions).
1.6 Create Next.js Project
bunx create-next-app@latest <project-name> \
--typescript \
--tailwind \
--app \
--src-dir \
--import-alias "@/*" \
--use-bun \
--yes
NOTE:
- Use
bun, notnpm - Use
bunx, notnpx - ESLint is NOT included â we use Biome
1.7 Install Biome
cd <project-name>
bun add -D @biomejs/biome
Create biome.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
}
}
1.8 Configure MCP
Configure MCP servers in the workspace root (NOT inside the Next.js project):
workspace/ â config goes HERE
âââ legacy-site/
âââ my-next-app/
âââ .mcp.json
Create .mcp.json:
{
"mcpServers": {
"migent": {
"command": "npx",
"args": ["-y", "migent", "mcp"]
}
}
}
1.9 Verify MCP
Start the Next.js dev server, then test MCP:
bun run dev
ir_capture(port: 3000, route: "/")
VERIFY: Returns JSON with elementCount > 0.
1.10 Copy Assets
mkdir -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 || true
Setup Checkpoint
Before proceeding, confirm ALL of the following:
- All 4 skills installed
- Legacy site running and accessible
- Next.js project created with Biome configured
- MCP returning captures for both sites
- Assets copied
IF ANY FAILS: Stop and report the error to the user.
PHASE 2: DISCOVERY
Use MCP tools to capture the legacy site and analyze patterns. DO NOT USE CURL. DO NOT FETCH HTML MANUALLY.
2.1 Discover Routes
Analyze legacy codebase to find all routes:
- Check
sitemap.xmlif exists - Check router files (Express routes, Next.js pages, PHP files)
- Check navigation links in captured IR
2.2 Capture All Routes (Parallel)
Call ir_capture for all discovered routes in parallel (batch all calls in a single message). Each ir_capture creates a fresh Playwright page â no shared state conflicts.
# 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 routes
Collect all results. Write captures to migration.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": []
}
2.3 Analyze JavaScript Patterns
IMPORTANT: Legacy JavaScript is ANYTHING that is NOT React or Next.js.
Search legacy codebase for patterns that need conversion:
# 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"
Document findings in migration.json under legacy.javascript.
2.4 Detect Locales & Validate Links
Check ir_capture results for locale patterns:
- Redirect-based detection: If
ir_capturereturnsredirects(e.g.,/â/en/), the site uses locale prefixes. - Route-based detection: If discovered routes have locale prefixes (e.g.,
/en/about,/fr/about), locales are in use.
If locales are detected:
- Set up Next.js i18n middleware for locale routing
- Create
src/middleware.tswith locale detection and redirect logic - Use
next-intlor Next.js built-in i18n for locale-aware Link components - Validate all internal links include the correct locale prefix
- Map each locale route to its Next.js equivalent
Internal link validation: Check ir_capture internalLinks against detected locales. Links missing locale prefixes will break in the migrated site.
2.5 Install Conditional Dependencies
Based on discovery results:
If animations were detected in any ir_capture:
cd <next-project>
bun add framer-motion
PHASE 3: MIGRATE (PER ROUTE)
For EACH route discovered in Phase 2, repeat steps 3.1 through 3.4. Start with / to build the shared shell first.
CRITICAL RULES â VIOLATIONS ARE FAILURES
FORBIDDEN:
dangerouslySetInnerHTMLâ NEVER use to copy legacy HTMLonclick="..."or any inline event handlers- jQuery or any jQuery patterns
<script>tags with inline JavaScriptclass=instead ofclassName=
REQUIRED:
- Proper JSX with
className - React event handlers (
onClick={handler}) - Match computed styles visually (Tailwind utilities, CSS modules, or global CSS â see Appendix A)
next/imagefor images- Server Components by default
'use client'only when needed
RECOMMENDED (enforce in Phase 5):
next/fontfor fonts (see Appendix C)- shadcn/ui components for form elements, dialogs, tables (see Appendix D)
- Tailwind utilities over CSS modules/global CSS
3.1 Build Page
For / (first route): also build the shared shell:
- Create
src/app/layout.tsx(RootLayout) with HTML structure, metadata - Extract shared components: Header, Footer, Nav â
src/components/ - Register components in
migration.jsonundercomponents
For all routes (including /):
Read captured IR from migration.json for this route.
Use ir_inspect(selector: "...", site: "legacy") to get full computed styles for specific elements.
Based on captured IR:
- Create
src/app/<route>/page.tsx - Convert layout structure to JSX
- Convert captured computed styles to Tailwind (see Appendix A)
- Convert event handlers to React patterns
- Import shared components from
src/components/â do NOT recreate them - Recreate animations using captured animation data (see Appendix B)
3.2 Code Quality Gate
BEFORE visual validation, verify no anti-patterns:
# 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/
ALL MUST RETURN: No results. Fix any violations before proceeding.
3.3 Visual Validation Loop
Start watch mode:
ir_start(legacyPort: <legacy-port>, nextPort: 3000, legacyRoute: "<route>", nextRoute: "<route>")
â { status: "watching", match: {...}, totalIssues: N, firstIssue: {...} }
Loop until match >= 95%:
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.4
CLS is a hard gate. ir_next will not serve style/content/missing issues until CLS score is “good” (<= 0.1). This is enforced by the tool, not by convention. You cannot skip it.
3.4 Mark Route Complete
ir_stop()
Update routeStatus to "validated" in migration.json. Move to next route.
If only skipped issues remain and match is below 95%: update routeStatus to "failed", mark route for human review, and continue.
PHASE 4: COMPLETION
4.1 Generate Report
Create MIGRATION_REPORT.md with:
- Summary (routes migrated, match percentages)
- Per-route breakdown
- Skipped issues requiring human review
- Components created
- Recommendations
4.2 Cleanup
ir_stop()
PHASE 5: MODERNIZE (OPTIONAL)
Post-migration pass to adopt modern component patterns and tooling. Run this after all routes pass visual parity in Phase 4. Each step is independent â skip any that don’t apply.
5.1 Install shadcn/ui
cd <next-project>
bunx shadcn@latest init -y
5.2 Configure shadcn MCP
Add to .mcp.json:
{
"mcpServers": {
"migent": {
"command": "npx",
"args": ["-y", "migent", "mcp"]
},
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
}
}
}
5.3 Install Components
Use ir_capture uiPatterns.shadcnComponentsNeeded data to install the right components:
# Example: if uiPatterns shows Button, Dialog, Table, NavigationMenu
bunx shadcn@latest add button dialog table navigation-menu -y
Also search for blocks that match legacy page patterns (e.g., login forms, dashboards, pricing pages).
5.4 Convert to shadcn Components
Replace raw HTML elements with shadcn equivalents (see Appendix D):
<button>â<Button><input>â<Input><table>â<Table><dialog>/.modalâ<Dialog>
5.5 Convert to Tailwind Utilities
Replace CSS modules and global CSS with Tailwind utilities where possible. Use Appendix A as a mapping reference.
5.6 Convert to next/font
Replace @font-face declarations with next/font (see Appendix C).
5.7 Verify No Visual Regressions
Run ir_start again after modernization to confirm no regressions:
ir_start(legacyPort: <legacy-port>, nextPort: 3000, legacyRoute: "<route>", nextRoute: "<route>")
Check that match percentages are unchanged or improved.
5.8 Code Quality Checks
# 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"
ERROR HANDLING
MCP tool fails
Stop and report to user. Do not attempt workarounds.
Site unreachable
Stop and ask user to restart the server.
RESUMABILITY
migration.json tracks per-route status in routeStatus:
| Status | Meaning |
|---|---|
pending |
Not yet migrated |
validated |
Passed visual validation (match >= 95%) |
failed |
Below 95% after exhausting fixes, needs human review |
If migration.json exists when /migration is invoked:
- Read existing state
- Skip
validatedroutes entirely - Resume from first
pendingroute - Retry
failedroutes if user requests it
APPENDIX A: Captured Styles â Tailwind Mapping
Use ir_inspect(selector: "...", site: "legacy") to get computed styles, then convert:
Colors (backgroundColor, color, borderColor):
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/50
Spacing (padding, margin):
padding: "16px" â p-4
padding: "15px 20px" â py-[15px] px-5
margin: "0 auto" â mx-auto
margin: "24px 0 0 0" â mt-6
Typography:
fontSize: "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-center
Layout:
display: "flex" â flex
display: "grid" â grid
flexDirection: "column" â flex-col
justifyContent: "center" â justify-center
alignItems: "center" â items-center
gap: "16px" â gap-4
Sizing:
width: "100%" â w-full
maxWidth: "1280px" â max-w-7xl
height: "auto" â h-auto
minHeight: "100vh" â min-h-screen
Position:
position: "absolute" â absolute
position: "relative" â relative
position: "fixed" â fixed
top: "0px" â top-0
left: "50%" â left-1/2
Borders:
borderRadius: "8px" â rounded-lg
borderRadius: "9999px" â rounded-full
borderWidth: "1px" â border
borderColor: "rgb(229,231,235)" â border-gray-200
Font Style:
fontStyle: "italic" â italic
fontStyle: "normal" â not-italic
Text Transform:
textTransform: "uppercase" â uppercase
textTransform: "lowercase" â lowercase
textTransform: "capitalize" â capitalize
textTransform: "none" â normal-case
Text Decoration:
textDecoration: "underline" â underline
textDecoration: "line-through" â line-through
textDecoration: "none" â no-underline
Overflow:
overflow: "hidden" â overflow-hidden
overflow: "auto" â overflow-auto
overflow: "scroll" â overflow-scroll
overflowX: "auto" â overflow-x-auto
overflowY: "hidden" â overflow-y-hidden
Grid:
gridTemplateColumns: "repeat(3, 1fr)" â grid-cols-3
gridTemplateColumns: "repeat(4, minmax(0, 1fr))" â grid-cols-4
gridTemplateColumns: "200px 1fr" â grid-cols-[200px_1fr]
Transform:
transform: "translateX(-50%)" â -translate-x-1/2
transform: "rotate(45deg)" â rotate-45
transform: "scale(1.1)" â scale-110
Effects:
opacity: "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-lg
Arbitrary values (when no Tailwind match):
padding: "13px" â p-[13px]
backgroundColor: "#c41e3a" â bg-[#c41e3a]
fontSize: "17px" â text-[17px]
maxWidth: "1140px" â max-w-[1140px]
APPENDIX B: Recreating Animations
From captured animations data in ir_capture:
CSS @keyframes â Framer Motion:
// 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" }}
>
CSS @keyframes â Tailwind animation:
/* 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]">
Transitions:
// Captured: { property: "background-color", duration: "0.2s", timingFunction: "ease" }
<button className="transition-colors duration-200 ease-in-out hover:bg-red-700">
jQuery animations â Framer Motion:
// 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>
APPENDIX C: Font Migration
Read font data from ir_capture response (fonts section). For each detected font family:
Google Fonts â next/font/google:
import { 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',
});
Custom Fonts â next/font/local:
import 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',
});
Apply in layout.tsx:
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${customFont.variable}`}>
<body>{children}</body>
</html>
);
}
Configure in tailwind.config.ts:
fontFamily: {
sans: ['var(--font-inter)', ...defaultTheme.fontFamily.sans],
custom: ['var(--font-custom)'],
},
Download font files: If ir_capture fonts[].src contains URLs, download .woff2 files to public/fonts/ for next/font/local.
APPENDIX D: shadcn Component Mapping (Phase 5)
Use shadcn components instead of raw HTML. Reference this during Phase 5 (Modernize). Map legacy elements:
| Legacy HTML | shadcn Component | Import |
|---|---|---|
<button>, <input type="submit"> |
<Button> |
@/components/ui/button |
<input type="text|email|password"> |
<Input> |
@/components/ui/input |
<textarea> |
<Textarea> |
@/components/ui/textarea |
<select> |
<Select> |
@/components/ui/select |
<table> |
<Table> |
@/components/ui/table |
<dialog>, .modal |
<Dialog> |
@/components/ui/dialog |
<nav> |
<NavigationMenu> |
@/components/ui/navigation-menu |
.card, <article> |
<Card> |
@/components/ui/card |
<input type="checkbox"> |
<Checkbox> |
@/components/ui/checkbox |
<input type="radio"> |
<RadioGroup> |
@/components/ui/radio-group |
.tabs, [role="tablist"] |
<Tabs> |
@/components/ui/tabs |
.accordion, <details> |
<Accordion> |
@/components/ui/accordion |
.breadcrumb |
<Breadcrumb> |
@/components/ui/breadcrumb |
.pagination |
<Pagination> |
@/components/ui/pagination |
Example conversion:
// 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>
Raw HTML elements (<button>, <input>, <table>, <dialog>) should be replaced in Phase 5.
Code quality checks (run during Phase 5):
# 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"
ALL MUST RETURN: No results (except shadcn verification which SHOULD return matches). These checks are enforced in Phase 5, not during Phase 3 migration.
APPENDIX E: MCP Tools Reference
ir_capture
Capture a page’s DOM tree, computed styles, animation metadata, and CLS score.
ir_capture(port: number, route?: string, width?: number, height?: number)
Deterministic capture sequence:
- Waits for network idle
- Forces all lazy images to load
- Waits for all images and fonts
- Extracts animation metadata (BEFORE finishing animations)
- Forces all animations to END STATE
- Waits for DOM stability
Returns:
- Layout patterns (header, nav, footer, sidebar, main)
- Component hierarchy
- Top-level elements with computed styles
- Animation data: keyframes, animatedElements, transitionElements, jQueryAnimations
- CLS data: score, rating, top shifters
- Font data (
fonts): totalFontFaces, fontFaces, uniqueFamilies - UI patterns (
uiPatterns): patterns with shadcnComponentsNeeded - Redirects (
redirects): Array of { from, to, statusCode } â useful for locale detection - Internal links (
internalLinks): { total, links[] } â for route validation
ir_start
Start migration watch mode. Captures both sites, diffs, starts file watcher, returns first issue.
ir_start(legacyPort, nextPort, legacyRoute?, nextRoute?, watchPaths?)
Returns: { status: "watching", match: {...}, totalIssues: N, firstIssue: {...} }.
ir_next
Get next issue to fix. Blocks on CLS gate and regressions.
ir_next(skip?: boolean)
skip: trueâ skip current issue after failed attempts, advance to next- Returns: Issue with selector, position, styles, and fix suggestion.
ir_status
Migration progress: match percentages, issue counts by severity, CLS score, regression state.
ir_inspect
Inspect element by selector or text.
ir_inspect(selector: string, site?: "legacy" | "next" | "both")
site="legacy"or"next": full styles, rect, snippet for one sidesite="both"(default): side-by-side comparison with style diffs
ir_stop
Stop watch mode and close browser.