migration

SKILL.md

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

  1. "Which directory contains your legacy site?"

    • Scan workspace for package.json, composer.json, Gemfile, index.html, index.php
    • Offer detected options as choices
  2. "What port is your legacy site running on?" (or "How do I start it?")

    • Common ports: 3000, 4000, 8000, 8080
  3. "What should I name the Next.js project?"

    • Suggest: <legacy-name>-next

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 (check version). Styles need latest tailwindcss and include shadcn (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, not npm
  • Use bunx, not npx
  • 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 Install shadcn/ui

REQUIRED for all migrations. shadcn provides accessible, production-ready components.

cd <project-name>
bunx shadcn@latest init -y

Install base components that most sites need:

bunx shadcn@latest add button input textarea select card -y

After running ir_capture in Phase 2, install additional components based on detected UI patterns:

# Example: if ir_capture shows uiPatterns with Dialog, Table, NavigationMenu
bunx shadcn@latest add dialog table navigation-menu -y

VERIFY:

ls components.json
ls src/components/ui/

MUST PASS: components.json exists and src/components/ui/ contains component files.

1.9 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 / .codex/config.toml

Claude Code — create .mcp.json:

{
  "mcpServers": {
    "migent": {
      "command": "npx",
      "args": ["-y", "migent", "mcp"]
    },
    "shadcn": {
      "command": "npx",
      "args": ["shadcn@latest", "mcp"]
    }
  }
}

Codex CLI — run:

codex mcp add migent -- npx -y migent mcp
codex mcp add shadcn -- npx shadcn@latest mcp

The shadcn MCP lets you browse, search, and install components via natural language. Use it to install components detected by ir_capture uiPatterns and to find blocks/templates that match legacy page structures.

1.10 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.11 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
  • shadcn/ui initialized with base components
  • 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.xml if exists
  • Check router files (Express routes, Next.js pages, PHP files)
  • Check navigation links in captured IR

2.2 Capture All Routes

For EACH discovered route:

ir_capture(port: <legacy-port>, route: "<route>")

Save results 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
  },
  "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:

  1. Redirect-based detection: If ir_capture returns redirects (e.g., //en/), the site uses locale prefixes.
  2. Route-based detection: If discovered routes have locale prefixes (e.g., /en/about, /fr/about), locales are in use. ir_start will confirm via localeConfig in Phase 3.

If locales are detected:

  • Set up Next.js i18n middleware for locale routing
  • Create src/middleware.ts with locale detection and redirect logic
  • Use next-intl or 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 Additional shadcn Components

Based on ir_capture uiPatterns data, install any remaining shadcn components. Use the shadcn MCP to browse and install:

"Install the dialog, table, and tabs components from shadcn"

Or via CLI:

bunx shadcn@latest add dialog table tabs accordion -y

Also search for blocks that match legacy page patterns (e.g., login forms, dashboards, pricing pages). Blocks provide pre-built layouts that accelerate migration.

VERIFY: All components listed in uiPatterns.shadcnComponentsNeeded are installed.

2.6 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.

CRITICAL RULES — VIOLATIONS ARE FAILURES

FORBIDDEN:

  • dangerouslySetInnerHTML — NEVER use to copy legacy HTML
  • onclick="..." or any inline event handlers
  • Legacy CSS class names (must convert to Tailwind)
  • jQuery or any jQuery patterns
  • <script> tags with inline JavaScript
  • class= instead of className=
  • Raw <button>, <input>, <textarea>, <select>, <table>, <dialog> outside components/ui/ — use shadcn
  • @font-face in CSS — use next/font (see Appendix C)

REQUIRED:

  • Proper JSX with className
  • React event handlers (onClick={handler})
  • Tailwind CSS utilities (convert from captured styles — see Appendix A)
  • shadcn/ui components for all form elements, dialogs, tables (see Appendix D)
  • next/image for images
  • next/font for fonts
  • Server Components by default
  • 'use client' only when needed

3.1 Build Page

Read captured IR from migration.json for this route.

Use ir_inspect(selector: "...", site: "legacy") to inspect specific elements.

Based on captured IR:

  1. Create app/<route>/page.tsx
  2. Convert layout structure to JSX
  3. Convert captured computed styles to Tailwind (see Appendix A)
  4. Convert event handlers to React patterns
  5. Create components for reusable parts (header, footer)
  6. 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 -rE 'className="[^"]*[a-z]+_[a-z]+' <next-project>/src/ --include="*.tsx"
grep -r "from ['\"]jquery['\"]" <next-project>/src/

# Font enforcement — must use next/font, not raw @font-face
grep -r "@font-face" <next-project>/src/

# shadcn enforcement — raw HTML elements FORBIDDEN outside components/ui/
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/'

ALL MUST RETURN: No results. Use shadcn components (<Button>, <Input>, <Table>, etc.) instead of raw HTML — see Appendix D.

# Verify shadcn IS being used (SHOULD return matches)
grep -rn "from ['\"]@/components/ui/" <next-project>/src/ --include="*.tsx"

Fix any violations before proceeding.

3.3 Visual Validation Loop

Start watch mode:

ir_start(legacyPort: <legacy-port>, nextPort: 3000, legacyRoute: "<route>", nextRoute: "<route>")

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 using Tailwind
  - 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 Verify and Mark Complete

ir_status()

Confirm:

  • match >= 95%
  • clsBlocked: false
  • clsRating: "good"

Mark route complete in migration.json. Move to next route.

If only skipped issues remain and match is below 95%: 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()

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

If migration.json exists when /migration is invoked:

  1. Read existing state
  2. Skip completed routes
  3. Resume from last in-progress route

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

MANDATORY: Use shadcn components instead of raw HTML. 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>) are FORBIDDEN outside src/components/ui/.

Additional code quality checks for shadcn/fonts:

# 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).


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:

  1. Waits for network idle
  2. Forces all lazy images to load
  3. Waits for all images and fonts
  4. Extracts animation metadata (BEFORE finishing animations)
  5. Forces all animations to END STATE
  6. 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, watches for file changes.

ir_start(legacyPort, nextPort, legacyRoute?, nextRoute?, watchPaths?)

Returns: Initial diff, first issue, and localeConfig (if locales detected in routes).

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

Get 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 side
  • site="both" (default): side-by-side comparison with style diffs

ir_stop

Stop watch mode and close browser.

Weekly Installs
3
Repository
alan-ws/migent
First Seen
Feb 11, 2026
Installed on
opencode3
codex3
claude-code3
github-copilot1