--- name: route-conversion description: > Use when converting a specific page from pages/ to app/ directory, or migrating a route end-to-end (file structure, data fetching, metadata, imports). The main workhorse skill for per-route migration. --- # Route Conversion Convert a specific page from pages/ directory to app/ directory, handling data fetching migration, metadata extraction, and file structure changes. ## Iron Law ``` ONE ROUTE AT A TIME. VALIDATE BEFORE MOVING ON. DELETE THE OLD FILE. ``` Every route follows the full cycle: analyze → convert → validate → delete old file → build. Skip a step? You'll ship broken code. "I'll validate later" means "I'll debug for hours later." **Prerequisites:** - **REQUIRED:** Run `migration-assessment` first if this is the start of a migration. No exceptions — even "simple" codebases have hidden blockers. - **REQUIRED:** Ensure `app/layout.tsx` exists before converting any route. If it doesn't, convert `pages/_app.tsx` + `pages/_document.tsx` first. ## Toolkit Setup This skill requires the `nextjs-migration-toolkit` skill to be installed. All migration skills depend on it for AST analysis. ```bash TOOLKIT_DIR="$(cd "$(dirname "$SKILL_PATH")/../nextjs-migration-toolkit" && pwd)" if [ ! -f "$TOOLKIT_DIR/package.json" ]; then echo "ERROR: nextjs-migration-toolkit is not installed." >&2 echo "Run: npx skills add blazity/next-migration-skills -s nextjs-migration-toolkit" >&2 echo "Then retry this skill." >&2 exit 1 fi bash "$TOOLKIT_DIR/scripts/setup.sh" >/dev/null ``` ## Version-Specific Patterns Before applying any migration patterns, check the target Next.js version. Read `.migration/target-version.txt` if it exists, or ask the user. Then read the corresponding version patterns file: ```bash SKILL_DIR="$(cd "$(dirname "$SKILL_PATH")" && pwd)" cat "$SKILL_DIR/../version-patterns/nextjs-.md" ``` **Critical version differences that affect route conversion:** - **Next.js 14**: `params` and `searchParams` are plain objects — direct access - **Next.js 15+**: `params` and `searchParams` are Promises — MUST `await` - **Next.js 14**: `cookies()` is SYNCHRONOUS - **Next.js 15+**: `cookies()` is ASYNC — MUST `await` The examples below show patterns that work across versions. Check the version patterns file for exact syntax. ## Steps ### 1. Analyze the Source Route ```bash npx tsx "$TOOLKIT_DIR/src/bin/ast-tool.ts" analyze routes npx tsx "$TOOLKIT_DIR/src/bin/ast-tool.ts" transform data-fetching npx tsx "$TOOLKIT_DIR/src/bin/ast-tool.ts" transform imports --dry-run npx tsx "$TOOLKIT_DIR/src/bin/ast-tool.ts" transform router ``` ### 2. Create App Router File Structure Map the pages/ path to app/ path: - `pages/index.tsx` → `app/page.tsx` - `pages/about.tsx` → `app/about/page.tsx` - `pages/blog/[slug].tsx` → `app/blog/[slug]/page.tsx` - `pages/api/posts.ts` → `app/api/posts/route.ts` - `pages/_app.tsx` → `app/layout.tsx` (root layout) - `pages/_document.tsx` → `app/layout.tsx` (merge into root layout) ### 3. Migrate Data Fetching Based on the data-fetching analysis, apply the appropriate pattern. See the `data-layer-migration` skill for detailed before/after examples of each pattern. | Pages Router | App Router | |-------------|------------| | `getStaticProps` | Async server component + `fetch()` with `{ cache: 'force-cache' }` | | `getStaticProps` + revalidate | Async server component + `{ next: { revalidate: N } }` | | `getServerSideProps` | Async server component + `{ cache: 'no-store' }` | | `getStaticPaths` | `export async function generateStaticParams()` | ### 4. Extract Metadata **Before** (using `next/head`): ```tsx import Head from 'next/head'; export default function AboutPage() { return ( <> About Us

About Us

); } ``` **After** (using metadata export): ```tsx import { Metadata } from 'next'; export const metadata: Metadata = { title: 'About Us', description: 'Learn about our company', openGraph: { title: 'About Us' }, }; export default function AboutPage() { return

About Us

; } ``` For dynamic metadata (needs params or fetched data), use `generateMetadata`: ```tsx import { Metadata } from 'next'; export async function generateMetadata({ params }): Promise { const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json()); return { title: post.title, description: post.excerpt }; } ``` Rules: - Remove all `next/head` imports and `` usage - Static metadata → `export const metadata: Metadata` - Dynamic metadata → `export async function generateMetadata()` - `next-seo` → replace with the metadata export API ### 5. Update Imports ```bash npx tsx "$TOOLKIT_DIR/src/bin/ast-tool.ts" transform imports ``` - Rewrite `next/router` to `next/navigation` - Remove `next/head` imports - Update component imports for new file paths ### 6. Validate and Finalize **This step is NOT optional. Do not proceed to the next route without completing it.** ```bash npx tsx "$TOOLKIT_DIR/src/bin/ast-tool.ts" validate ``` After validation passes: 1. **Delete the old pages/ file.** The route must not exist in both directories. 2. **Run `npx next build`** to catch type errors, missing exports, and boundary violations. 3. Confirm the build succeeds before claiming this route is done. ## Route Completion Checklist Before moving to the next route, verify ALL of these: - [ ] New `app/` file created with correct naming (`page.tsx` inside a folder) - [ ] Data fetching migrated (no getStaticProps/getServerSideProps/getStaticPaths) - [ ] Metadata extracted (no `next/head` or `` usage) - [ ] Imports updated (`next/navigation`, not `next/router`) - [ ] Validator passes with no errors - [ ] Old `pages/` file deleted - [ ] `npx next build` succeeds **Cannot check all boxes? The route is not done. Fix issues before proceeding.** ## Red Flags — STOP If You Catch Yourself Thinking: - "I'll validate all the routes at the end" — No. Validate each route individually. Batch errors compound. - "I'll delete the old files later" — No. Delete immediately. Forgetting creates conflict errors. - "It compiles, so it's done" — Compiling is not validating. Run the validator AND build. - "This is a simple page, I can skip analysis" — Simple pages have hidden dependencies. Run the analyzer. - "I don't need to run assessment first, I can see what needs migrating" — Assessment catches blockers you can't see by reading code. ## Common Pitfalls ### Wrong file naming in app/ directory **Wrong**: `app/about.tsx` (will not be recognized as a route) **Right**: `app/about/page.tsx` (every route needs a `page.tsx` inside a folder) **Exception**: `app/page.tsx` is the root route (replaces `pages/index.tsx`) ### Forgetting the root layout **Error**: `A required layout.tsx file was not found at root level` **Fix**: The `app/` directory must have a root `layout.tsx`. Convert `pages/_app.tsx` and `pages/_document.tsx` into: ```tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ### Using `router.query` after migration **Error**: `router.query` is undefined or doesn't exist on the App Router's router. **Cause**: App Router's `useRouter()` (from `next/navigation`) does not have `.query`. Use `useParams()` for path parameters and `useSearchParams()` for query strings. **Fix**: ```tsx // Before (Pages Router) const { slug, page } = router.query; // After (App Router) import { useParams, useSearchParams } from 'next/navigation'; const { slug } = useParams(); const searchParams = useSearchParams(); const page = searchParams.get('page'); ``` ### Missing Suspense boundary for useSearchParams() **Error**: Page using `useSearchParams()` renders an error shell or empty HTML instead of content. Next.js logs: `useSearchParams() should be wrapped in a suspense boundary`. **Cause**: In App Router, `useSearchParams()` in a client component triggers a client-side-only render unless wrapped in ``. Without it, Next.js cannot statically render the page and returns an error boundary. **Fix**: Wrap the component using `useSearchParams()` in a `` boundary. The cleanest approach is a server page that wraps a client component: ```tsx // app/search/page.tsx (server component) import { Suspense } from 'react'; import { SearchContent } from './search-content'; export default function SearchPage() { return ( Loading...

}>
); } ``` ```tsx // app/search/search-content.tsx (client component) 'use client'; import { useSearchParams } from 'next/navigation'; export function SearchContent() { const searchParams = useSearchParams(); const q = searchParams.get('q') || ''; // ... render search results } ``` **Rule**: EVERY use of `useSearchParams()` MUST have a `` boundary as an ancestor. No exceptions. ### Returning inline fallback JSX instead of notFound() **Wrong**: When `getServerSideProps` returned `{ notFound: true }`, replacing it with inline JSX that renders "not found" text with a 200 status: ```tsx // WRONG — returns HTTP 200 with "not found" text export default async function UserPage({ params }) { const user = getUser(params.id); if (!user) return

User not found

; // HTTP 200! return
{user.name}
; } ``` **Right**: Use `notFound()` from `next/navigation` to trigger the proper HTTP 404 response and the nearest `not-found.tsx` boundary: ```tsx import { notFound } from 'next/navigation'; export default async function UserPage({ params }) { const user = getUser(params.id); if (!user) notFound(); // HTTP 404! return
{user.name}
; } ``` **Rule**: If the original `getServerSideProps` returned `{ notFound: true }`, the migrated page MUST call `notFound()` from `next/navigation`. NEVER replace it with inline fallback JSX. ### Using manual wrapper components instead of nested layout.tsx **Wrong**: The Pages Router code wraps every dashboard page in a `` component. You keep doing the same in App Router: ```tsx // WRONG — manually wrapping each page // app/dashboard/page.tsx import { DashboardLayout } from '@/components/DashboardLayout'; export default function DashboardPage() { return

Dashboard

; } // app/dashboard/settings/page.tsx import { DashboardLayout } from '@/components/DashboardLayout'; export default function SettingsPage() { return

Settings

; } ``` **Right**: Create a `layout.tsx` in the shared route segment. App Router renders it automatically around all child pages: ```tsx // app/dashboard/layout.tsx — applied automatically to ALL /dashboard/* pages export default function DashboardLayout({ children }: { children: React.ReactNode }) { return (
{children}
); } ``` ```tsx // app/dashboard/page.tsx — NO manual wrapper needed export default function DashboardPage() { return

Dashboard

; } ``` **Rule**: When multiple pages in `pages/section/*` all import and render the same layout wrapper component, create `app/section/layout.tsx` instead. Do NOT manually wrap each page. ### Route conflict from not deleting old file **Error**: `Conflicting app and page file was found` or route resolves to wrong page. **Cause**: The same route exists in both `pages/` and `app/`. Next.js does not know which to use. **Fix**: Delete the `pages/` file immediately after converting and validating the `app/` version. Do not batch deletions — delete as part of each route's conversion cycle.