Code migration can be a challenging task, but AI agents are making it easier.
At Money Forward, we’re working on tech stack standardization to increase development velocity across our engineering teams. By consolidating our frontend technologies, we can share knowledge more effectively and build reusable components that work across different products.
We chose the Stampless project in our Cloud Contract product as our first trial for migrating from Vue to React. This gave us a concrete case study to understand the challenges, develop best practices, and measure the impact of using AI agents in the migration process.
With the help of AI agents, we automated much of the heavy lifting—from migrating API calls and component logic to updating UIs and running tests. This made the entire process faster and more efficient. In this blog post, we’ve shared the lessons we learned along th way.
How the current Industry utilize AI for code migration?
Before starting the migration, we explored AI-based migration examples from industry to understand how AI can assist with code generation. Here are some recommended resources for you to review:
- How is Google using AI for internal code migrations?
- Evaluating Human-AI Partnership for LLM-based Code Migration
- Human-In-the-Loop Software Development Agents
- Refactoring Programs Using Large Language Models with Few-Shot Examples
- Demystifying LLM-based Software Engineering Agents
Key Results for The Stampless Migration Project
In our Stampless frontend migration project, we’ve achieved significant efficiency gains in the migration process by utilizing AI. Below, we break down the measurable time savings across key workflows.
- API call migration: ~90% time saved compared with manual migration.
- Component logic migration: ~40% time saved compared with manual migration.
- Workflow Impact: Overall estimation: Saving 40% time of entire Stampless migration project. Time spent on migration for a complex page reduced from ~13 days to ~5 days.
Frontend Code Migration Breakdown
When migrating Stampless’ Vue codebase to React, many utility functions and tests can stay the same since both use TypeScript. The main challenge is identifying and addressing the differences between the two frameworks.
Below are the three key differences between React and Vue in this migration:
- UI Components: Vue and React write components differently. Some UI elements also need updates, so we’ll recreate them based on the new Figma design.
- Data Fetching: The Vue code uses a general-purpose state manager for API calls (written imperatively). In React, we’ll use TanStack Query for a more declarative approach.
- Component Logic: Vue bundles logic inside class methods, while React moves it into reusable custom hooks.

UI Components Migration As mentioned earlier, we need a way to convert Figma designs into React components. To do this, we feed the Figma design details through Figma MCP (link). For simple pages, the AI can generate the exact UI, using the correct components from the library and correct design tokens from the design system.

Data Fetching & Component Logic When it comes to data fetching, we’ve found great success in converting API calls from Vue to React. The process works smoothly in most cases, and we can handle the conversion efficiently in a single step.
The example below demonstrates how we migrated the createAddressBook
API call. The AI agent will follow the predefined coding style, project structure, and TypeScript interface to create a React version using TanStack Query, based on the original Vue API calls.
If you’d like to learn more about the prompt we use with the AI agent, feel free to check out the appendix section at the end of this post.

The good news is that AI tools can also handle component logic conversion quite well. When we need to transform the internal logic of components from Vue to React, we’ve consistently achieved similar positive results.
Our Key Takeaways
Throughout our work on the migration, we have been exploring how to improve the AI’s accuracy. We have found that with the right approach, we can make the migration much more successful. Here are a few key points to keep in mind:
Human-in-the-loop
In an ideal world, an AI agent could handle migrations entirely on its own, without any human involvement. However, we’ve found that, at least as of mid-2025, having a human in the loop still makes a significant difference. (We may revisit this stance in the future as AI evolves.)
The Atlassian team has proposed a workflow that we’ve found particularly effective (checkout the detail from Atlassian’s paper). Instead of letting AI agents handle everything from start to finish, their approach includes human review at each stage before proceeding to the next. This ensures the output meets quality expectations before it’s passed along.

Without human oversight, AI agents can still complete tasks, but the final results often require much more manual adjustment. Unfortunately, this often ends up costing more human time overall.
To ensure the best outcomes, we recommend including a review or feedback process with human when using AI agents. This helps maintain accuracy and reliability.
Breaking down the scope
To achieve high migration accuracy, it’s important to find the “just right” scope.
As Adam Hofmann from the Cursor team explains (link):
For example, in our Stampless we broke the work into smaller steps: converting API calls, updating the UI, and adjusting component logic. We found that asking the AI to handle all three at once often led to poorer results because the context became too overwhelmingly large.
However, when we tackled each task separately, the AI performed with much higher accuracy.
Breaking down the scope doesn’t mean slowing down. We recommend the Parallelization pattern, as suggested by the Anthropic team (link). As illustrated in the diagram below, this approach allows multiple AI tasks to run simultaneously—like converting UI from Figma and updating API calls from Vue to React—since these tasks are usually independent of each other.

Setting up the foundation
The success of a migration project isn’t decided when the work begins—it’s determined long before, by the groundwork laid in advance.
For an AI agent to work effectively, the new codebase needs clear guardrails: a well-defined style guide, a logical repository structure, and a thorough README. These aren’t just nice-to-haves; they’re the rules of the road. Without them, the AI has no map to follow, and the output will be inconsistent

We recommend creating a dedicated guidelines folder to organize all your documentation. For example, in the screenshot above, you’ll see a file like coding-standard.md
, which explains how to write code in a preferred style for the codebase. By providing these clear instructions, the AI agent can follow the rules and generate code that aligns with your codebase, ensuring consistency.
Leverage the tools
To help AI agents work effectively, providing the right tools is essential. We recommend two types of tools for migration projects:
First, feedback tools, like linting or testing tools. These give the AI agent immediate feedback on its work. For example, if there’s a linting error, the agent can fix it right away. Below is a screenshot showing how the agent corrected an error based on the linter’s feedback.

Second, MCP tools, which connect the AI agent to external resources. In our frontend migration project, we used Figma MCP to access detailed design information (link). This was far more helpful than just sharing a screenshot, as it allowed the agent to convert designs much more accurately.
When using the MCP tool, it’s important to start with a solid foundation. For example, with Figma MCP, we’ve found that alignment between Figma and the codebase is key. The component names in Figma should match exactly with those in the codebase in order to get an accurate conversion result.
Without this alignment, the AI agent might generate something that looks like the Figma design but doesn’t work correctly—because it could create the component with a different behavior from scratch instead of reusing the existing one from the codebase.
Conclusion
AI agents are changing how we approach code migration. The numbers speak for themselves: 90% time savings on API migrations, 40% on component logic, and an overall 40% reduction in project time. What took 13 days now takes 5.
But the real lesson isn’t about the speed gains—it’s about the approach. AI agents work best when they’re part of a system, not a magic solution. You need human oversight at key checkpoints. You need to break complex tasks into manageable pieces. You need clear guidelines and the right tools.
The foundation matters most. Before you start any migration, set up your guardrails: style guides, repository structure, documentation. These aren’t overhead—they’re the difference between AI that helps and AI that creates more work.
If you’re considering AI for your next migration project, start small. Pick one well-defined task, like API call conversion. Set up proper human review points. Use feedback tools like linters. Once you see what works, expand from there.
This blog post isn’t the end—it’s just the beginning. At Money Forward, we’ll keep exploring how AI can boost our engineering productivity. If this effort excites you, we’d love to have you join us! Feel free to reach out anytime.
Appendix: Sample Migration Prompts
The prompts we used were crucial to achieving our 90% time savings on API migrations. Below is the complete prompt template we developed for Vue-to-React API conversion:
## IDENTITY and PURPOSE
You are an expert frontend developer specializing in migrating Vue API calls to React tanstack query patterns. Your task is to help migrate API-related code while maintaining functionality and improving code quality.
## Required
- `<vue_code>`: The Vue.js API-related code to be migrated
- `<vue_api_interface>`: The API interface definition from the Vue project
- `<migration_context>`: Additional context about the API migration (optional)
## GUIDELINES
- Convert Vue API calls to React hooks using tanstack/query
- Follow established API patterns in the React codebase
- Use TypeScript for type safety
- Place API files in correct directory structure: `apps/web/src/features/[specific-page]/apis/[file-name].ts` Make sure you search through the codebase first before deciding the location to place the API files
- For API types, reuse types from the Vue version where available
- Ensure proper error handling with specific error codes
- Reuse existing API services from `packages/api/src/generated-apis.ts`
- Implement cache invalidation or optimistic updates with the correct query keys
## STEPS
1. Analyze the Vue API interface and call structure:
- Endpoint and HTTP method
- Request payload type
- Response type
- Error handling patterns and error codes
2. Create corresponding React API handler:
- Import required services from generated-apis.ts
- Reuse API types from Vue project
- Set up query key constants that match existing conventions
- Implement error handling with specific error codes and toast messages
- Use handleMutationError for generic errors
3. Implement the API hook using tanstack/query:
- useQuery for GET requests
- useMutation for POST/PUT/DELETE
- Add proper TypeScript types
- Set up success/error callbacks
- Add loading state handling
- Implement optimistic updates with the correct query invalidation keys
## Migration Examples
We will input and output like below:
### Input Example
// API Types from Vue project
export interface IAddressBook {
id: string;
name: string;
email: string;
companyName?: string; // Converted from company_name
department?: string;
telephoneNumber?: string; // Converted from telephone_number
createdAt: string; // Converted from created_at
updatedAt: string; // Converted from updated_at
}
// API Service from Vue project
const ADDRESS_BOOK_PATH = '/address_books';
export function getAddressBook() {
return requestGet(`${ADDRESS_BOOK_PATH}`);
}
export function deleteAddressBook(id: string) {
return requestDelete(`${ADDRESS_BOOK_PATH}/${id}`);
}
export function createAddressBook(payload: any) {
return requestPost(`${ADDRESS_BOOK_PATH}`, payload);
}
export function updateAddressBook(id: string, payload: any) {
return requestPut(`${ADDRESS_BOOK_PATH}/${id}`, payload);
}
### Output Example
Query hook for GET operations:
// apps/web/src/features/personal-settings/address-book/apis/use-query-address-book.ts
import { useQuery } from '@tanstack/react-query';
import { IAddressBook } from '@/features/personal-settings/address-books/models/address-book';
import { ADDRESS_BOOK_PATH } from '@/shared/config/query-key';
import { toCamelCase } from '@/shared/utils/case-converter';
import { AddressBooksService } from '@repo/api';
/**
* Fetches address book data from the API
*
* @returns Promise resolving to an array of address book entries
*/
const getAddressBookQueryFn = async (): Promise<IAddressBook[]> => {
const response =
(await AddressBooksService.addressBooksList()) as unknown as {
data: { data: IAddressBook[] };
};
return toCamelCase<IAddressBook[]>(response.data.data);
};
export const useQueryAddressBook = () => {
const query = useQuery({
queryKey: [ADDRESS_BOOK_PATH],
queryFn: getAddressBookQueryFn,
});
return query;
};
Create hook for POST operations:
// apps/web/src/features/personal-settings/address-book/apis/use-create-address-book.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
AddressBookCreatePayload,
IAddressBook,
} from '@/features/personal-settings/address-books/models/address-book';
import { ADDRESS_BOOK_PATH } from '@/shared/config/query-key';
import { ApiError } from '@/shared/utils/error-utils';
import { handleMutationError } from '@/shared/utils/handle-mutation-error';
import { AddressBooksService } from '@repo/api';
import { toast } from '@repo/ui';
export const toastMessages = {
success: '追加が完了しました。',
errors: {
CODE_ADDRESS_BOOK_DUPLICATED_PARTNER_EMAIL:
'このメールアドレスはすでに登録されているため、アドレス帳の保存に失敗しました。',
CODE_INVALID_PAYLOAD: '入力内容に誤りがあります。',
},
};
export const createAddressBookMutationFn = async (
payload: AddressBookCreatePayload
) => {
return AddressBooksService.addressBooksCreate(payload);
};
export const useCreateAddressBook = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createAddressBookMutationFn,
onMutate: async (newAddressBook) => {
// Cancel any outgoing refetches
// (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: [ADDRESS_BOOK_PATH] });
// Snapshot the previous value
const previousAddressBooks = queryClient.getQueryData([
ADDRESS_BOOK_PATH,
]);
// Optimistically update to the new value
queryClient.setQueryData([ADDRESS_BOOK_PATH], (old: IAddressBook[]) => [
...old,
newAddressBook,
]);
// Return a context object with the snapshotted value
return { previousAddressBooks };
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (error: ApiError, _, context) => {
// Restore the previous data if available
queryClient.setQueryData(
[ADDRESS_BOOK_PATH],
context?.previousAddressBooks
);
// Handle error messages
const errorMessage =
toastMessages.errors[
error.response?.data?.errors?.[0]
?.code as keyof typeof toastMessages.errors
];
handleMutationError(
error,
errorMessage
? {
silent: true,
onError: () => {
toast.failure({
key: ADDRESS_BOOK_PATH,
message: errorMessage,
});
},
}
: {}
);
},
onSuccess: () => {
toast.success({
key: ADDRESS_BOOK_PATH,
message: toastMessages.success,
});
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [ADDRESS_BOOK_PATH] });
},
});
};
Update hook for PUT operations:
// apps/web/src/features/personal-settings/address-book/apis/use-update-address-book.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
AddressBookUpdatePayload,
IAddressBook,
} from '@/features/personal-settings/address-books/models/address-book';
import { ADDRESS_BOOK_PATH } from '@/shared/config/query-key';
import { ApiError } from '@/shared/utils/error-utils';
import { handleMutationError } from '@/shared/utils/handle-mutation-error';
import { AddressBooksService } from '@repo/api';
import { toast } from '@repo/ui';
export const toastMessages = {
success: '編集が完了しました。',
errors: {
CODE_ADDRESS_BOOK_DUPLICATED_PARTNER_EMAIL:
'このメールアドレスはすでに登録されているため、アドレス帳の保存に失敗しました。',
CODE_INVALID_ADDRESS_BOOK_ID:
'アドレス帳の編集に失敗しました。指定されたIDが無効です。',
CODE_NOT_FOUND_ADDRESS_BOOK:
'アドレス帳の編集に失敗しました。このアドレスはすでに削除されている可能性があります、一度画面を更新して再度確認してください。',
},
};
export const updateAddressBookMutationFn = (params: {
id: string;
payload: AddressBookUpdatePayload;
}) => {
const { id, payload } = params;
return AddressBooksService.addressBooksUpdate(id, payload);
};
export const useUpdateAddressBook = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateAddressBookMutationFn,
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: [ADDRESS_BOOK_PATH, variables.id],
});
// Snapshot the previous value
const previousAddressBook = queryClient.getQueryData<IAddressBook>([
ADDRESS_BOOK_PATH,
variables.id,
]);
// Optimistically update to the new value
if (previousAddressBook) {
queryClient.setQueryData<IAddressBook>(
[ADDRESS_BOOK_PATH, variables.id],
{
...previousAddressBook,
name: variables.payload.name,
email: variables.payload.email,
companyName: variables.payload.company_name,
department: variables.payload.department,
telephoneNumber: variables.payload.telephone_number,
}
);
}
return { previousAddressBook, variables };
},
onError: (error: ApiError, variables, context) => {
// Revert back to the previous value if there's an error
if (context?.previousAddressBook) {
queryClient.setQueryData(
[ADDRESS_BOOK_PATH, variables.id],
context.previousAddressBook
);
}
const errorMessage =
toastMessages.errors[
error.response?.data?.errors?.[0]
?.code as keyof typeof toastMessages.errors
];
handleMutationError(
error,
errorMessage
? {
silent: true,
onError: () => {
toast.failure({
key: ADDRESS_BOOK_PATH,
message: errorMessage,
});
},
}
: {}
);
},
onSettled: (data, error, variables) => {
// Always invalidate the queries to ensure data consistency
queryClient.invalidateQueries({ queryKey: [ADDRESS_BOOK_PATH] });
queryClient.invalidateQueries({
queryKey: [ADDRESS_BOOK_PATH, variables.id],
});
},
onSuccess: () => {
toast.success({
key: ADDRESS_BOOK_PATH,
message: toastMessages.success,
});
},
});
};
Delete hook for DELETE operations:
// apps/web/src/features/personal-settings/address-book/apis/use-delete-address-book.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ADDRESS_BOOK_PATH } from '@/shared/config/query-key';
import { ApiError } from '@/shared/utils/error-utils';
import { handleMutationError } from '@/shared/utils/handle-mutation-error';
import { AddressBooksService } from '@repo/api';
import { toast } from '@repo/ui';
export const toastMessages = {
success: 'アドレス帳の削除が完了しました。',
errors: {
CODE_INVALID_ADDRESS_BOOK_ID:
'アドレス帳の削除に失敗しました。すでにこのレコードは削除されている可能性があります。',
CODE_NOT_FOUND_ADDRESS_BOOK:
'アドレス帳の編集に失敗しました。このアドレスはすでに削除されている可能性があります、一度画面を更新して再度確認してください。',
},
};
export const deleteAddressBookQueryFn = (id: string) => {
return AddressBooksService.addressBooksDelete(id);
};
export const useDeleteAddressBook = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteAddressBookQueryFn,
onMutate: async (id) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: [ADDRESS_BOOK_PATH] });
// Snapshot the previous value
const previousAddressBooks = queryClient.getQueryData<IAddressBook[]>([
ADDRESS_BOOK_PATH,
]);
// Optimistically update to the new value by removing the deleted item
if (previousAddressBooks) {
queryClient.setQueryData<IAddressBook[]>(
[ADDRESS_BOOK_PATH],
previousAddressBooks.filter((item) => item.id !== id)
);
}
return { previousAddressBooks, id };
},
onError: (error: ApiError, _, context) => {
// Revert back to the previous value if there's an error
if (context?.previousAddressBooks) {
queryClient.setQueryData(
[ADDRESS_BOOK_PATH],
context.previousAddressBooks
);
}
const errorMessage =
toastMessages.errors[
error.response?.data?.errors?.[0]
?.code as keyof typeof toastMessages.errors
];
handleMutationError(
error,
errorMessage
? {
silent: true,
onError: () => {
toast.failure({
key: ADDRESS_BOOK_PATH,
message: errorMessage,
});
},
}
: {}
);
},
onSuccess: () => {
toast.success({
key: ADDRESS_BOOK_PATH,
message: toastMessages.success,
});
},
onSettled: () => {
// Always invalidate queries to ensure data consistency
queryClient.invalidateQueries({ queryKey: [ADDRESS_BOOK_PATH] });
},
});
};