Next.js Pages to App Migration skill for Codex
Move route by route and ship after each one. Codex inventories every pages/ file with a bundled script that scores migration effort and flags URLs defined in both routers, then moves routes lowest-effort first. It lifts and shifts each page into an async server component, maps getStaticPaths, next/head and next/router to their app/ equivalents, and checks status codes, metadata and rendering mode before deleting the old file.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- nextjs-pages-to-app-migration Installs to ~/.agents/skills/nextjs-pages-to-app-migration/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install nextjs-pages-to-app-migration The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project nextjs-pages-to-app-migration Installs to .agents/skills/nextjs-pages-to-app-migration/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/nextjs-pages-to-app-migration.zipunzip -o nextjs-pages-to-app-migration.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) nextjs-pages-to-app-migration PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $nextjs-pages-to-app-migration, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Migrates a Next.js app from the pages/ router to the app/ router one route at a time — root layout from _app and _document, getServerSideProps, getStaticProps and getStaticPaths to server components and generateStaticParams, next/head to the Metadata API, next/router to next/navigation, and API routes to route handlers — with both routers running side by side. Use when planning or executing a pages-to-app migration, converting a single page, or when a mixed app has route conflicts, hard reloads between routers, or broken router hooks.
Use it when
- Planning or running a pages/ to app/ router migration
- Converting one page that uses getServerSideProps or getStaticProps
- A mixed app has route conflicts or broken router hooks in shared components
- Replacing next/head and router.query in migrated pages
Use something else when
- Building new routes in an app that is already fully on app/: use nextjs-app-router
- Choosing cache lifetimes after the move: use nextjs-caching
How to check the result
The build passes, the inventory script reports no conflicts, and each migrated URL returns the same status, title, canonical and robots tags as before with existing e2e tests green.
The mistake it prevents
Migrating a page whose analytics or loading bar hooks into router.events, which silently stops firing on app/ routes.
Done well means: the app ships after every migrated route, no URL exists in both
routers, each moved page keeps its status codes, metadata, redirects and data freshness,
and the pages/ directory shrinks in small reviewable commits instead of one rewrite.
Ground rules
- Migrate incrementally. Both routers run in one app; nothing forces a big bang.
- Never define the same URL in both
pages/andapp/; that is a build error. Move a route by creating the app/ version and deleting the pages/ file in the same commit. - Navigation between a pages/ route and an app/ route is a full page load. Migrate routes that link to each other heavily (a section and its children) together.
- Upgrade Next.js to the target major on the pages router first, ship that, then start moving routes. Mixing a major upgrade with a router migration hides the cause of every regression.
- Keep behavior identical first; refactor to new patterns (streaming, server actions) after the route is on app/.
Workflow
- [ ] 1. Inventory routes and score effort
- [ ] 2. Upgrade Next.js on the pages router and ship
- [ ] 3. Create the root layout from _app and _document
- [ ] 4. Migrate routes, lowest effort first, one PR each (or one section)
- [ ] 5. Move special files (404, 500, _error) last
- [ ] 6. Delete pages/ when only API routes (optional) remain
1. Inventory
Run the bundled script from the skill folder (Node 20+, no dependencies):
node scripts/pages_inventory.mjs <project-root> # markdown table
node scripts/pages_inventory.mjs <project-root> --json # for further processing
It lists every pages/ file with its URL, data-fetching functions, next/router and
next/head usage, the suggested app/ target and an effort score. It exits 1 if a URL
already exists in both routers. Paste the table into the migration issue as the plan.
Also read AGENTS.md, next.config.* (look for i18n, basePath, rewrites,
redirects), and the test setup. The pages router i18n config has no app/ equivalent;
plan locale routing (step 4) before migrating localized pages.
3. Root layout
// app/layout.tsx
import "./globals.css"; // global CSS imports from _app move here
import { Providers } from "./providers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: { default: "Acme", template: "%s | Acme" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// app/providers.tsx
"use client";
export function Providers({ children }: { children: React.ReactNode }) {
// theme, query client, analytics: whatever _app wrapped around <Component />
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
_documentcustomizations (lang, body classes, fonts,beforeInteractivescripts) go into the root layout. Fonts:next/fontin the layout._appper-page layouts (Component.getLayout) become nestedlayout.tsxfiles.pages/_appkeeps serving the remaining pages/ routes; do not delete it yet.
4. Migrate a route
Lift and shift first: make the app/ page a server component that fetches what
getServerSideProps/getStaticProps returned, and render the existing page component
(marked "use client" if it uses hooks) with the same props.
// Before: pages/orders/[id].tsx
export const getServerSideProps: GetServerSideProps = async ({ params, req }) => {
const session = await getSession(req);
if (!session) return { redirect: { destination: "/login", permanent: false } };
const order = await getOrder(String(params!.id), session.userId);
if (!order) return { notFound: true };
return { props: { order } };
};
export default function OrderPage({ order }: Props) { /* hooks, UI */ }
// After: app/orders/[id]/page.tsx
import { notFound, redirect } from "next/navigation";
import { OrderView } from "./order-view"; // the old component, now "use client"
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const session = await getSession(); // reads cookies() internally
if (!session) redirect("/login");
const { id } = await params;
const order = await getOrder(id, session.userId);
if (!order) notFound();
return <OrderView order={order} />;
}
Mapping table:
| pages/ | app/ |
|---|---|
getServerSideProps | async server component; read cookies()/headers() from next/headers |
getStaticProps | cached data fetch (fetch with cache: "force-cache", or "use cache" with Cache Components) |
revalidate: 60 | export const revalidate = 60, or cacheLife under Cache Components |
getStaticPaths | generateStaticParams |
fallback: false | export const dynamicParams = false (with Cache Components: call notFound()) |
fallback: "blocking" | default behavior |
fallback: true | loading.tsx or <Suspense> around the data |
{ notFound: true } / { redirect } | notFound() / redirect() / permanentRedirect() |
getInitialProps | server component data fetch; remove it from _app as early as possible |
next/head | export const metadata or generateMetadata |
useRouter from next/router | useRouter, usePathname, useSearchParams, useParams from next/navigation |
router.query | params / searchParams page props, or useParams / useSearchParams |
router.events | effect on usePathname() + useSearchParams() changes |
router.locale, i18n config | app/[lang]/ segment plus locale detection in proxy.ts (middleware.ts before Next 16) |
pages/api/x.ts | may stay; or app/api/x/route.ts with Web Request/Response |
pages/404.tsx, pages/500.tsx, _error | app/not-found.tsx, app/global-error.tsx, error.tsx |
next/legacy/image | next/image |
Shared components used by both routers: import useRouter from next/compat/router;
it returns null under app/, so branch on that instead of crashing.
API route conversion (only when you have a reason; pages/api keeps working):
// app/api/users/[id]/route.ts
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await findUser(id);
return user ? Response.json(user) : Response.json({ error: "not found" }, { status: 404 });
}
req.query becomes new URL(req.url).searchParams (or request.nextUrl.searchParams
with NextRequest); res.status(x).json(y) becomes Response.json(y, { status: x }).
Body parser config (export const config = { api: { bodyParser } }) has no equivalent;
read the body with req.json(), req.formData() or req.text().
Codemods
npx @next/codemod@latest provides transforms for version upgrades (async request
APIs, next/image and others); run it without arguments to choose interactively. It
needs network access for npx; in a sandbox without network, ask the user to approve or
apply the transform by hand. No codemod converts data-fetching functions; do those
manually with the table above.
Gotchas
getServerSidePropsran per request; a server component with an uncached fetch also does, but a page with no request APIs and only cached fetches becomes static. Check the build route table to confirm the rendering mode did not change by accident.- Props passed from the server page to the old client component must be serializable.
Pages that returned
Datevia superjson or class instances need explicit mapping. - SWR or React Query
fallback/initialDatafromgetServerSidePropscan stay: pass the server-fetched data asinitialDatato the client component until you refactor. next/headtags inside client components are ignored in app/. Move all metadata tometadata/generateMetadata.useSearchParamsin a statically rendered app/ page must sit inside<Suspense>.- Middleware matchers written for pages/ URLs may need updates for new route groups and locale segments.
- Analytics page-view tracking hooked to
router.eventssilently stops on migrated routes. Re-implement it before migrating high-traffic pages.
Verification per migrated route
-
next buildpasses and the script reports no conflicts. - Same HTTP status,
<title>, meta description, canonical and robots tags as before (curl -s <url> | grep -E '<title>|canonical|robots'before and after). - Redirects and 404s behave as before for logged-out users and missing records.
- Links to and from the route work; note which are now hard navigations between routers.
- Existing e2e tests for the route pass unchanged.
Output
Per PR: the inventory rows moved (from the script table), a before/after note on rendering mode, and any behavior intentionally changed. Keep the remaining inventory in the tracking issue up to date.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Next.js Pages to App Migration"
short_description: "Move route by route and ship after each one"
default_prompt: "$nextjs-pages-to-app-migration Plan our migration from pages/ to app/ and tell me which routes to move first" scripts/pages_inventory.mjs
#!/usr/bin/env node
// Build a migration worklist for a Next.js pages/ directory.
// Usage: node scripts/pages_inventory.mjs <project-root> [--json]
// Prints one row per route: URL, source file, data-fetching APIs, router/head usage,
// suggested app/ target and an effort score. Exit 0 = ok, 1 = a URL is defined in both
// pages/ and app/ (Next.js build error), 2 = bad arguments or no pages directory.
// Standard library only (Node 20+).
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
const args = process.argv.slice(2);
const asJson = args.includes('--json');
const root = args.find((a) => !a.startsWith('--'));
if (!root || !existsSync(root) || !statSync(root).isDirectory()) {
console.error('usage: node pages_inventory.mjs <project-root> [--json]');
process.exit(2);
}
const pick = (...c) => c.map((d) => join(root, d)).find((d) => existsSync(d));
const pagesDir = pick('pages', 'src/pages');
const appDir = pick('app', 'src/app');
if (!pagesDir) {
console.error(`no pages/ or src/pages/ under ${root}`);
process.exit(2);
}
const EXT = /\.(tsx|ts|jsx|js|mdx)$/;
const walk = (dir) =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = join(dir, e.name);
return e.isDirectory() ? walk(p) : EXT.test(e.name) ? [p] : [];
});
// URL for a pages/ file, or a special role.
const SPECIAL = {
_app: 'layout.tsx + providers.tsx (client)',
_document: 'layout.tsx (<html>/<body>)',
_error: 'error.tsx + global-error.tsx',
404: 'not-found.tsx',
500: 'global-error.tsx',
};
const toUrl = (rel) => {
const noExt = rel.replace(EXT, '').split(sep).join('/');
const segs = noExt.split('/');
if (segs[segs.length - 1] === 'index') segs.pop();
return '/' + segs.join('/');
};
// Normalise dynamic segment names so /blog/[slug] and /blog/[id] collide.
const shape = (url) => url.replace(/\[\[?\.{0,3}[^\]]+\]\]?/g, '[]');
const appPrefix = appDir ? relative(root, appDir).split(sep).join('/') : relative(root, pagesDir).split(sep).join('/').replace(/pages$/, 'app');
const appUrls = new Map();
if (appDir) {
for (const f of walk(appDir)) {
const name = f.split(sep).pop().replace(EXT, '');
if (name !== 'page' && name !== 'route') continue;
const segs = relative(appDir, f).split(sep).slice(0, -1)
.filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith('@'));
appUrls.set(shape('/' + segs.join('/')), relative(root, f));
}
}
const rows = [];
for (const f of walk(pagesDir).sort()) {
const rel = relative(pagesDir, f);
const src = readFileSync(f, 'utf8');
const base = rel.replace(EXT, '');
const has = (re) => re.test(src);
const apis = [];
if (has(/export\s+(async\s+)?(function|const)\s+getServerSideProps/)) apis.push('getServerSideProps');
if (has(/export\s+(async\s+)?(function|const)\s+getStaticProps/)) apis.push('getStaticProps');
if (has(/export\s+(async\s+)?(function|const)\s+getStaticPaths/)) {
const fb = src.match(/fallback\s*:\s*(true|false|['"]blocking['"])/);
apis.push(`getStaticPaths(fallback=${fb ? fb[1].replace(/['"]/g, '') : '?'})`);
}
if (has(/\.getInitialProps\s*=|getInitialProps\s*\(/)) apis.push('getInitialProps');
if (has(/revalidate\s*:/)) apis.push('ISR revalidate');
const usage = [];
if (has(/from\s+['"]next\/router['"]/)) usage.push('next/router');
if (has(/router\.query|useRouter\(\)\.query/)) usage.push('router.query');
if (has(/from\s+['"]next\/head['"]/)) usage.push('next/head');
if (has(/from\s+['"]next\/amp['"]|amp\s*:\s*true/)) usage.push('AMP (unsupported in app/)');
if (has(/from\s+['"]next\/legacy\/image['"]/)) usage.push('next/legacy/image');
if (has(/useEffect\([^)]*fetch\(/s)) usage.push('client fetch in effect');
let url, target, kind;
if (SPECIAL[base]) {
kind = 'special'; url = '-'; target = `${appPrefix}/${SPECIAL[base]}`;
} else if (base === 'api' || base.startsWith(`api${sep}`)) {
kind = 'api'; url = toUrl(rel);
target = `${appPrefix}${url}/route.ts (optional; pages/api can stay)`;
} else {
kind = 'page'; url = toUrl(rel);
target = `${appPrefix}${url === '/' ? '' : url}/page.tsx`;
}
let effort = 1 + apis.length + usage.length;
if (apis.includes('getInitialProps')) effort += 2;
if (kind === 'special') effort = base === '_app' || base === '_document' ? 3 : 1;
const conflict = kind !== 'special' && appUrls.has(shape(url)) ? appUrls.get(shape(url)) : null;
rows.push({ file: relative(root, f), kind, url, apis, usage, target, effort, conflict });
}
const conflicts = rows.filter((r) => r.conflict);
if (asJson) {
console.log(JSON.stringify({ pagesDir: relative(root, pagesDir) || '.', appDir: appDir ? relative(root, appDir) : null, rows }, null, 2));
} else {
console.log(`pages dir: ${relative(root, pagesDir)} app dir: ${appDir ? relative(root, appDir) : '(none yet)'}`);
console.log(`${rows.length} files: ${rows.filter((r) => r.kind === 'page').length} pages, ${rows.filter((r) => r.kind === 'api').length} API routes, ${rows.filter((r) => r.kind === 'special').length} special files\n`);
console.log('| Effort | URL | Source | Data APIs | Router/head | Target |');
console.log('|---|---|---|---|---|---|');
for (const r of [...rows].sort((a, b) => a.effort - b.effort || a.url.localeCompare(b.url))) {
console.log(`| ${r.effort} | ${r.url} | ${r.file} | ${r.apis.join(', ') || '-'} | ${r.usage.join(', ') || '-'} | ${r.target} |`);
}
console.log('\nEffort: 1 = static page, higher = more data APIs and router/head rewrites. Migrate low scores first.');
}
if (conflicts.length) {
console.error(`\n${conflicts.length} URL(s) defined in both pages/ and app/ (build error):`);
for (const c of conflicts) console.error(` ${c.url}: ${c.file} vs ${c.conflict}`);
process.exit(1);
}
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.