I built an AI agent that migrates Next.js Pages Router to App Router A developer built migrate-bot, an AI agent that automates the end-to-end migration of Next.js Pages Router projects to App Router. The tool handles semantic changes including converting `getStaticProps` to async Server Components, `next/router` to `next/navigation`, and restructuring `pages/` to `app/` with dynamic routes. The serverless pipeline runs through stages from queuing to PR readiness, with automatic refunds triggered if a migration fails mid-process. Most Next.js teams have a Pages Router → App Router migration sitting in their backlog. It's mechanical but careful work, and it keeps getting deprioritized. I built migrate-bot https://migrate-bot.dev to automate it end-to-end, and this post is about how it works under the hood. App Router isn't just "move files to a new folder". The semantic changes: getStaticProps / getServerSideProps → async Server Components getStaticPaths → generateStaticParams next/router → next/navigation useRouter / usePathname / useSearchParams next/head → the Metadata API export const metadata pages/ app.tsx + pages/ document.tsx → app/layout.tsx pages/api/x.ts single handler → app/api/x/route.ts export async function GET/POST/... pages/ → app/ restructure, including slug dynamic routesThe whole thing runs serverless + ephemeral: queued → analyzing → planning → migrating → verifying → pr ready ↓ on failure aborted blocker → refunding → refunded Each transition is persisted to D1 so every job is observable, and a mid-pipeline crash transitions to aborted blocker → automatic refund rather than leaving the customer charged for nothing. Early on, every repo with a pages/ document.tsx failed. The planner maps both app.tsx and document.tsx to the same target app/layout.tsx , and my migrate step recorded the second one as a "failed task" when it detected the target was already written. The pipeline treated any failed task as fatal — so the whole migration aborted. The fix was to distinguish intentional skips planned target collisions from real failures the LLM aborting or erroring . I added a skippedTaskIds field separate from failedTaskIds , and surfaced the skip in the PR description so the customer knows to manually merge any custom