Legacy System Migration
This skill guides the migration of legacy frontend systems to this project's modern architecture: Next.js 16 App Router, feature-based modules, Zod schemas, SSR-first data fetching, and Tailwind CSS.
IMPORTANT: Migration is incremental, not big-bang. Migrate one feature at a time. Old and new code can coexist during transition. Always start by analyzing the legacy code thoroughly before touching anything.
Migration Philosophy
- •Incremental, not big-bang — Migrate one feature at a time. The old and new can coexist.
- •Schema-first — Start every migration by defining Zod schemas for the data. This forces clarity about the data model before touching UI.
- •Test before and after — Write tests for legacy behavior before migrating. Verify they pass with the new code.
- •Feature boundaries first — Group related code into a feature module before refactoring internals.
- •Delete aggressively — Once migrated, remove the old code. Don't leave commented-out legacy code.
- •Preserve behavior — Functional parity is mandatory. UX improvements come AFTER migration.
- •Preserve URLs — Routes should keep the same URLs after migration.
Migration Map
| Legacy Pattern | Target Pattern | Guide |
|---|---|---|
Pages Router (pages/) | App Router (src/app/) | examples/pages-router-to-app-router.md |
getServerSideProps / getStaticProps | Server Components + prefetch + <Hydrate> | examples/pages-router-to-app-router.md |
_app.tsx / _document.tsx | layout.tsx + provider hierarchy | examples/pages-router-to-app-router.md |
next/router | next/navigation | examples/pages-router-to-app-router.md |
router.query | useParams() + useSearchParams() | examples/pages-router-to-app-router.md |
router.events | usePathname() in useEffect | examples/pages-router-to-app-router.md |
| Redux / Context API (server data) | React Query (useQuery, useMutation) | examples/state-management-migration.md |
| Redux / Context API (UI state) | Zustand in stores/[name]-store.ts | examples/state-management-migration.md |
| Axios / manual fetch | apiClient() + serverApiClient() | examples/state-management-migration.md |
| Manual loading/error states | TanStack Query + feedback components | examples/state-management-migration.md |
| Hand-written interfaces | Zod schemas + z.infer | examples/types-to-zod-schemas.ts |
| PropTypes | Zod schemas + z.infer | examples/types-to-zod-schemas.ts |
any / untyped | Zod schema with strict validation | examples/types-to-zod-schemas.ts |
Monolithic src/components/ | Feature-based src/features/ | examples/monolithic-to-feature-based.md |
| CSS Modules / Styled Components / SCSS | Tailwind CSS v4 via cn() | See styling section below |
| Inline styles | Tailwind CSS v4 via cn() | See styling section below |
| Class components | Functional components + hooks | See component section below |
| HOCs (Higher-Order Components) | Custom hooks or composition | See component section below |
| Render props | Custom hooks | See component section below |
return null on empty data | EmptyState from @/features/feedback | See component section below |
| Manual loading/error UI | LoadingState / ErrorState from @/features/feedback | See component section below |
| Hardcoded test data | Mock factories (createMock[Entity]) | See testing section below |
| Global state for everything | React Query (server) + Zustand (client) | examples/state-management-migration.md |
| No API envelope | ApiResponse<T> envelope | See API section below |
Migration Decision Tree: State Management
code
What kind of state is this?
├── Server/API data (tasks, users, etc.)
│ ├── Was in Redux → Migrate to React Query (useQuery/useMutation)
│ ├── Was in Context → Migrate to React Query
│ ├── Was in local state (useState + useEffect + fetch) → Migrate to React Query
│ └── Was fetched with Axios/SWR → Replace with apiClient() + React Query
├── Client-only UI state (sidebar open, modal visible, filters)
│ ├── Shared across components → Zustand store in stores/[name]-store.ts
│ └── Local to one component → useState (keep as-is)
└── Unsure?
└── If it comes from an API → React Query. Otherwise → Zustand or useState.
Migration Sequence (recommended)
For a complete feature migration, follow this order:
code
1. Analyze — Read ALL legacy code, identify patterns 2. Schemas & Types — Define Zod schemas, infer types 3. API Layer — Migrate endpoints to route handlers + ApiResponse<T> 4. Data Fetching — Replace manual fetch with apiClient/serverApiClient 5. State Management — Replace Redux/Context with React Query + Zustand 6. Feature Structure — Move files into src/features/[name]/ structure 7. Components — Migrate to functional, Tailwind, shadcn/ui 8. Pages — Convert to thin App Router pages + SSR-first 9. Tests — Create mock factories, write unit + E2E tests 10. Barrel Export — Create index.ts with full public API 11. Navigation — Add nav item to shell/constants/navigation.ts 12. Cleanup — Delete legacy files, unused deps, dead code 13. Verify — tsc --noEmit, vitest run, next build
Styling Migration
CSS Modules → Tailwind
tsx
// ❌ Before: CSS Modules
import styles from './Card.module.css';
<div className={styles.card}>
<h2 className={styles.title}>{title}</h2>
</div>
// ✅ After: Tailwind via cn()
import { cn } from "@/lib/utils";
<div className={cn("rounded-lg border bg-card p-6 shadow-sm")}>
<h2 className={cn("text-lg font-semibold")}>{title}</h2>
</div>
Styled Components → Tailwind
tsx
// ❌ Before: Styled Components
const StyledButton = styled.button`
background: blue;
color: white;
padding: 8px 16px;
border-radius: 4px;
`;
// ✅ After: shadcn/ui Button
import { Button } from "@/components/ui/button";
<Button variant="default">Click me</Button>
Inline Styles → Tailwind
tsx
// ❌ Before: inline styles
<div style={{ display: 'flex', gap: '8px', padding: '16px' }}>
// ✅ After: Tailwind
<div className="flex gap-2 p-4">
Component Migration
Class Component → Functional
tsx
// ❌ Before: Class component
class TaskList extends React.Component {
state = { tasks: [], loading: true };
componentDidMount() { this.fetchTasks(); }
render() { ... }
}
// ✅ After: Functional with React Query
"use client";
import { useTasks } from "@/features/tasks";
import { ErrorState, LoadingState, EmptyState } from "@/features/feedback";
function TaskList() {
const { data: tasks, isLoading, error } = useTasks();
if (error) return <ErrorState error={error} />;
if (isLoading) return <LoadingState />;
if (!tasks?.length) return <EmptyState title="No tasks" />;
return <ul>...</ul>;
}
HOC → Custom Hook
tsx
// ❌ Before: HOC
const withAuth = (Component) => (props) => {
const user = useContext(AuthContext);
if (!user) return <Redirect to="/login" />;
return <Component {...props} user={user} />;
};
// ✅ After: Hook + middleware protection
// Middleware handles auth redirect — no need for HOC
// Component just uses the session:
import { useSession } from "next-auth/react";
function Dashboard() {
const { data: session } = useSession();
// session is guaranteed by middleware
}
next/router → next/navigation
tsx
// ❌ Before: Pages Router
import { useRouter } from "next/router";
const router = useRouter();
const { id } = router.query;
router.push("/tasks");
// ✅ After: App Router
import { useRouter, useParams } from "next/navigation";
const router = useRouter();
const { id } = useParams();
router.push("/tasks");
API Migration
Manual API → ApiResponse envelope
typescript
// ❌ Before: inconsistent response shapes
app.get('/api/tasks', (req, res) => {
res.json(tasks); // raw array
});
app.get('/api/tasks/:id', (req, res) => {
res.json({ task }); // wrapped differently
});
// ✅ After: consistent ApiResponse<T> envelope
export async function GET() {
try {
const tasks = await db.tasks.findMany();
return Response.json({
data: tasks,
error: null,
meta: { total: tasks.length },
});
} catch {
return Response.json(
{ data: null, error: { message: "Failed to fetch tasks", code: "FETCH_ERROR" } },
{ status: 500 }
);
}
}
getServerSideProps → Server Component + Prefetch
tsx
// ❌ Before: getServerSideProps
export async function getServerSideProps(context) {
const tasks = await fetchTasks(context.query.filter);
return { props: { tasks } };
}
export default function TasksPage({ tasks }) {
return <TaskList tasks={tasks} />;
}
// ✅ After: Server Component + prefetch + Hydrate
import { Hydrate } from "@/lib/hydrate";
import { TaskList, prefetchTasks } from "@/features/tasks";
export default async function TasksPage() {
await prefetchTasks();
return (
<Hydrate>
<TaskList />
</Hydrate>
);
}
Testing Migration
Hardcoded data → Mock factories
typescript
// ❌ Before: hardcoded inline
const task = { id: '1', title: 'Test', status: 'pending' };
// ✅ After: mock factory with overrides
import { createMockTask } from "../mocks/task.mock";
const task = createMockTask({ status: "done" });
Critical Checks After Migration
- • No
pages/directory remnants (fully on App Router) - • No
getServerSideProps/getStaticProps/getInitialProps - • No
_app.tsx/_document.tsx - • No
next/routerimports (usenext/navigation) - • No Redux / Context for server state (use React Query)
- • No manual TypeScript interfaces for data (use Zod
z.infer) - • No CSS Modules / Styled Components / SCSS (use Tailwind)
- • No class components
- • No HOCs for auth/data (use hooks + middleware)
- • No
return nullfor empty data (useEmptyState) - • No hardcoded test data (use mock factories)
- • All features have barrel exports (
index.ts) - • All pages are thin (prefetch + Hydrate + render)
- • All data-fetching components handle empty/error/loading states
- • Navigation added to shell if needed
- •
tsc --noEmitpasses - •
vitest runpasses - •
next buildsucceeds
DO NOT
- •DO NOT do a big-bang rewrite — migrate one feature at a time.
- •DO NOT change behavior during migration — preserve functional parity first.
- •DO NOT leave commented-out legacy code — delete it completely.
- •DO NOT hand-write TypeScript interfaces — always use Zod schemas +
z.infer. - •DO NOT put server data in Zustand — use React Query for API/server data.
- •DO NOT forget to delete old files after migration — clean up completely.
- •DO NOT skip verification after each feature migration — run tsc, vitest, and next build.
- •DO NOT skip the analysis step — read ALL legacy code before touching anything.
- •DO NOT migrate without creating mock factories — tests need them.
- •DO NOT return
nullfor empty data — useEmptyStatefrom@/features/feedback.