·11 min read

Modernizing Legacy Code with AI Coding Assistants

A practical guide to using AI coding tools for safe, incremental legacy code modernization -- from jQuery to React, callbacks to async/await, and beyond.

Legacy code isn't going away, but modernizing it just got easier

Every engineering team has legacy code. That jQuery spaghetti powering the admin panel. The Express server full of deeply nested callbacks. The Angular 1.x frontend that nobody wants to touch because the original developer left three years ago.

Rewriting from scratch is almost always the wrong call. It takes too long, introduces new bugs, and risks breaking workflows that users depend on. But living with outdated patterns forever slows down every feature you build on top of them.

AI coding assistants have changed the math here. Tools like Cursor, Claude Code, and Windsurf are remarkably good at understanding old code patterns and translating them into modern equivalents -- file by file, function by function. The key is doing it safely and incrementally instead of trying to modernize everything at once.

This guide covers a practical approach to modernizing legacy codebases with AI, including migration rules, test-first workflows, and real patterns you can apply today.

Why AI tools are well-suited for legacy modernization

Modernizing legacy code is mostly pattern translation. Convert class components to functional components. Replace callbacks with async/await. Swap jQuery DOM manipulation for React state. Migrate CommonJS require() calls to ES module import statements.

These transformations are repetitive, rule-bound, and context-sensitive -- exactly the kind of work where AI assistants excel. A human developer doing a jQuery-to-React migration spends most of their time on mechanical translation, not creative problem-solving. An AI assistant can handle the mechanical parts in seconds, freeing you to focus on the design decisions that actually matter.

The other advantage is comprehension. AI tools can read a 400-line legacy function and explain what it does before you touch it. That alone saves hours of archaeology when working with unfamiliar code.

Start with a migration plan, not a migration

Before asking the AI to rewrite anything, you need a plan. A migration plan defines what you're modernizing, in what order, and what "done" looks like for each piece.

Here's a template that works for most legacy modernization projects:

## Migration Plan

### Scope
- Module: src/admin/ (jQuery + vanilla JS admin panel)
- Target: React 19 with TypeScript
- Timeline: incremental, one page per sprint

### Migration order (by dependency)
1. Shared utilities (date formatting, API helpers)
2. Authentication flow (login, session management)
3. Dashboard page
4. User management page
5. Settings page

### Rules
- No big-bang rewrites. One file or component at a time.
- Every migrated file must have tests before and after.
- Legacy and modern code must coexist during the transition.
- No new features in legacy code. New work goes in the modern stack.

Paste this plan into your AI rules file (CLAUDE.md, .cursor/rules/, or .windsurf/rules/) so the assistant has it as context for every session. For more on structuring rules effectively, see 10 Best Practices for Writing AI Coding Rules.

Write tests before you rewrite code

This is the single most important rule for safe legacy modernization: write tests for the existing behavior before you change anything.

Legacy code usually lacks tests. That means you have no way to verify that a rewrite preserves the original behavior. AI tools make writing these characterization tests fast:

Read src/admin/userTable.js and write tests that capture its current behavior.
Focus on:
- What the function returns for valid input
- How it handles null/undefined values
- Any side effects (DOM manipulation, API calls)
Use Jest with jsdom for the DOM tests.

The AI will read the legacy code, identify the key behaviors, and write tests for them. Review these tests carefully -- they're your safety net for the rewrite.

Once the tests pass against the original code, you can rewrite the implementation and run the same tests again. If they still pass, you know the behavior is preserved. If something breaks, you catch it immediately instead of discovering it in production a month later.

This test-first approach works especially well with AI pair programming workflows. Ask the AI to write the tests, review them, then ask it to do the rewrite.

Create migration rules for your AI assistant

Generic prompts produce generic rewrites. For consistent, high-quality modernization, encode your migration patterns as explicit rules.

Here's an example migration rules file for a jQuery-to-React conversion:

## jQuery to React Migration Rules

### General
- Replace jQuery DOM manipulation with React state and refs
- Convert $.ajax calls to fetch with async/await
- Replace jQuery event handlers with React event props
- Remove all jQuery imports from migrated files

### Component structure
- One component per file
- Use functional components with hooks, never class components
- Extract reusable logic into custom hooks in src/hooks/
- Props must be typed with TypeScript interfaces

### State management
- Replace global jQuery state with React useState/useReducer
- For shared state across components, use React context
- Never store DOM state -- derive it from React state

### Naming
- Components: PascalCase (UserTable, not user-table)
- Hooks: camelCase with "use" prefix (useUserData)
- Event handlers: handle + Event (handleClick, handleSubmit)

### Testing
- Every migrated component needs a test file
- Test user interactions, not implementation details
- Use React Testing Library, not Enzyme

Save this as a dedicated rules file (e.g., .cursor/rules/migration.mdc or a section in your CLAUDE.md). The AI reads these rules on every interaction, so it applies the patterns consistently across hundreds of file migrations.

You can also publish migration rules as shareable skills on localskills.sh so that other teams doing similar migrations can install them with a single command. See How to Migrate from .cursorrules to Shared Agent Skills for the full workflow.

Incremental modernization patterns

Here are specific patterns for the most common legacy-to-modern transitions, with prompts you can adapt for your own codebase.

jQuery to React

This is one of the most common migrations. The key insight is that jQuery code is usually organized around DOM elements, while React code is organized around state. The AI needs to identify what state the jQuery code is managing through DOM manipulation.

Read src/admin/userTable.js. This is a jQuery component that renders a user table
with sorting and pagination.

Rewrite it as a React functional component in TypeScript. Follow the migration
rules in .cursor/rules/migration.mdc.

Specifically:
1. Identify all state stored in the DOM (sort column, sort direction, current page)
2. Move that state into useState hooks
3. Replace $.ajax with a custom hook that uses fetch
4. Replace jQuery event handlers with React event props
5. Keep the same visual structure and class names so CSS still works

Callbacks to async/await

Legacy Node.js code is often full of callback pyramids. This migration is mostly mechanical but has one important catch: error handling patterns change.

Refactor src/services/orderService.js from callback style to async/await.

Rules:
- Every function that takes a callback should become an async function
- Replace callback(null, result) with return result
- Replace callback(err) with throw err
- Wrap external library calls that still use callbacks with util.promisify
- Add try/catch blocks around IO operations (database, file system, HTTP)
- Preserve the existing error messages and error codes

CommonJS to ES Modules

Convert src/utils/ from CommonJS to ES modules.

- Replace require() with import
- Replace module.exports with named exports
- Replace module.exports = function with export default function
  (only for files that export a single function)
- Update all files that import from these utils
- Make sure circular dependencies are resolved

Class components to functional components (React)

Convert src/components/Dashboard.jsx to a functional component with hooks.

- Replace this.state with useState hooks (one per state field)
- Replace componentDidMount with useEffect
- Replace componentDidUpdate with useEffect with dependencies
- Replace componentWillUnmount with useEffect cleanup functions
- Convert class methods to const functions
- Add TypeScript types for all props and state
- Rename the file to Dashboard.tsx

For more on structuring AI prompts that produce reliable code output, see AI Code Generation: How to Get Better Output Every Time.

Managing the coexistence period

Real modernization happens over weeks or months, not days. During that time, legacy code and modern code need to coexist peacefully. This is where most migration projects fail -- not in the rewriting, but in the integration.

Set clear boundaries

Establish a convention for where legacy code lives and where modern code lives. For example:

src/
  legacy/         # Untouched legacy code
  components/     # Modern React components
  hooks/          # Modern custom hooks
  utils/          # Migrated utilities (ES modules, TypeScript)
  legacy-utils/   # Not-yet-migrated utilities

Add this directory structure to your AI rules so the assistant knows where to put new code versus where to find old code.

Create bridge layers

When modern code needs to call legacy code (or vice versa), create explicit bridge files rather than mixing patterns within a single file:

// src/bridges/userApi.ts
// Bridge: wraps legacy callback-based user API for modern async consumers

import { promisify } from 'util';
import { getUser as getUserCallback } from '../legacy/userService';

export const getUser = promisify(getUserCallback);

These bridges make the boundary visible and give you a clear checklist of what still needs migrating. When the legacy service is rewritten, you delete the bridge and update imports.

Prevent regression into legacy patterns

Add a rule to your AI configuration that prevents new code from using legacy patterns:

## Anti-patterns (never use in new code)
- No jQuery in any file under src/components/ or src/hooks/
- No require() -- use import/export only
- No callback-style async -- use async/await
- No var -- use const or let
- No class components -- use functional components with hooks

This is one of the highest-value uses of AI coding rules. The assistant won't introduce require() in a new file if you've explicitly told it not to. For more on encoding team standards this way, read AI Pair Programming: The Developer's Guide.

Tracking progress and measuring success

Legacy modernization can feel endless without visible progress markers. Track your migration at the file level:

## Migration tracker

### Migrated (React + TypeScript)
- [x] src/components/UserTable.tsx
- [x] src/components/LoginForm.tsx
- [x] src/hooks/useUserData.ts

### In progress
- [ ] src/components/Dashboard.tsx (blocked on settings API migration)

### Not started
- [ ] src/admin/settings.js
- [ ] src/admin/reports.js

Keep this tracker in a markdown file in the repository. Update it as part of each migration PR. When the AI sees this file in context, it understands the current state of the migration and can suggest the next logical file to work on.

Measure success with concrete metrics:

  • Files migrated vs. remaining -- the simplest progress indicator
  • Test coverage on migrated code -- should be higher than the legacy baseline
  • Bundle size change -- removing jQuery and other legacy dependencies usually shrinks the bundle
  • Time to implement new features -- should decrease as more code moves to the modern stack

Common pitfalls to avoid

Trying to modernize everything at once. Pick one module. Migrate it completely. Ship it. Then move to the next one. Partial migrations across many modules create a confusing codebase where nobody knows which pattern to follow.

Skipping tests. Without tests, you can't verify that the rewrite preserves behavior. The AI can write characterization tests quickly. Use them.

Over-abstracting during migration. The goal is to translate existing behavior to modern patterns, not to redesign the architecture. Resist the temptation to refactor and modernize at the same time. Migrate first, refactor later.

Ignoring the build system. Switching from CommonJS to ES modules, from JavaScript to TypeScript, or from jQuery to React often requires build configuration changes. Make sure your bundler, test runner, and CI pipeline handle the new file formats before you start migrating source files.

Not updating the AI rules as you go. Your migration rules should evolve as you learn what works. If the AI keeps making the same mistake in a particular pattern, add a rule for it. Treat your rules file as a living document that gets better with every file you migrate.

Sharing migration knowledge across teams

If your organization has multiple teams dealing with similar legacy code, the migration rules and patterns you develop are valuable beyond your own project.

Publishing your migration rules as shared skills on localskills.sh means that another team starting a similar migration can install your battle-tested rules instead of starting from scratch:

# Install proven jQuery-to-React migration rules
localskills install your-org/jquery-to-react-migration --target cursor claude windsurf

This turns one team's hard-won migration experience into an organizational asset. The rules are versioned, so you can update them as you discover new edge cases without breaking what other teams have already installed.


Legacy code modernization used to be a grind of manual file-by-file translation. With AI coding assistants and well-defined migration rules, it's faster, safer, and more consistent. Start with tests, define your rules, work incrementally, and share what you learn.

Sign up for localskills.sh to publish and share your migration rules across every AI coding tool your team uses.

npm install -g @localskills/cli
localskills login
localskills publish