Migrating from Pages Router to App Router can be tricky, and this guide walks you through it systematically. It covers the full spectrum from understanding file conventions (layout.tsx, page.tsx, loading.tsx) to actually moving your routes and metadata over. The TypeScript guidance is strong, especially the hard rule against using `any` types since it'll break your build. I appreciate the side-by-side comparisons showing old Pages Router structure versus new App Router patterns. The migration steps are concrete: create root layout, move pages to the app directory, update navigation, clean up the old pages folder. It also covers metadata handling, nested layouts, and common routing patterns like dynamic routes and route groups.
npx -y skills add wsimmonds/claude-nextjs-skills --skill nextjs-app-router-fundamentals --agent claude-codeInstalls into .claude/skills of the current project.
Provide comprehensive guidance for Next.js App Router (Next.js 13+), covering migration from Pages Router, file-based routing conventions, layouts, metadata handling, and modern Next.js patterns.
any TypeCRITICAL RULE: This codebase has @typescript-eslint/no-explicit-any enabled. Using any will cause build failures.
❌ WRONG:
function handleSubmit(e: any) { ... }
const data: any[] = [];
✅ CORRECT:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
// Page props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }
// Server actions
async function myAction(formData: FormData) { ... }
Use this skill when:
pages/ directory) to App Router (app/ directory)Pages Router (Legacy - Next.js 12 and earlier):
pages/
├── index.tsx # Route: /
├── about.tsx # Route: /about
├── _app.tsx # Custom App component
├── _document.tsx # Custom Document component
└── api/ # API routes
└── hello.ts # API endpoint: /api/hello
App Router (Modern - Next.js 13+):
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Route: /
├── about/ # Route: /about
│ └── page.tsx
├── blog/
│ ├── layout.tsx # Nested layout
│ └── [slug]/
│ └── page.tsx # Dynamic route: /blog/:slug
└── api/ # Route handlers
└── hello/
└── route.ts # API endpoint: /api/hello
Special Files in App Router:
layout.tsx - Shared UI for a segment and its children (preserves state, doesn't re-render)page.tsx - Unique UI for a route, makes route publicly accessibleloading.tsx - Loading UI with React Suspenseerror.tsx - Error UI with Error Boundariesnot-found.tsx - 404 UItemplate.tsx - Similar to layout but re-renders on navigationroute.ts - API endpoints (Route Handlers)Colocation:
app/page.tsx and route.ts files create public routesExamine existing Pages Router setup:
pages/ directory structure_app.tsx - handles global state, layouts, providers_document.tsx - customizes HTML structurenext/head, <Head> component)Create app/layout.tsx - REQUIRED for all App Router applications:
// app/layout.tsx
export const metadata = {
title: 'My App',
description: 'App description',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Migration Notes:
_document.tsx HTML structure to layout.tsx_app.tsx global providers/wrappers to layout.tsx<Head> metadata to metadata export<html> and <body> tagsSimple Page Migration:
// Before: pages/index.tsx
import Head from 'next/head';
export default function Home() {
return (
<>
<Head>
<title>Home Page</title>
</Head>
<main>
<h1>Welcome</h1>
</main>
</>
);
}
// After: app/page.tsx
export default function Home() {
return (
<main>
<h1>Welcome</h1>
</main>
);
}
// Metadata moved to layout.tsx or exported here
export const metadata = {
title: 'Home Page',
};
Nested Route Migration:
// Before: pages/blog/[slug].tsx
export default function BlogPost() { ... }
// After: app/blog/[slug]/page.tsx
export default function BlogPost() { ... }
Replace anchor tags with Next.js Link:
// Before (incorrect in App Router)
<a href="/about">About</a>
// After (correct)
import Link from 'next/link';
<Link href="/about">About</Link>
After migration:
pages/ directorypages/api/ if you're not migrating API routes yet_app.tsx and _document.tsx (functionality moved to layout)pages/ directory// app/page.tsx or app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'My Page',
description: 'Page description',
keywords: ['nextjs', 'react'],
openGraph: {
title: 'My Page',
description: 'Page description',
images: ['/og-image.jpg'],
},
};
// app/blog/[slug]/page.tsx
export async function generateMetadata({
params
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
};
}
// app/layout.tsx - Root layout
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/blog/layout.tsx - Blog layout
export default function BlogLayout({ children }) {
return (
<div>
<BlogSidebar />
<main>{children}</main>
</div>
);
}
Layout Behavior:
// app/blog/[slug]/page.tsx
export default function BlogPost({
params
}: {
params: { slug: string }
}) {
return <article>Post: {params.slug}</article>;
}
// app/shop/[...slug]/page.tsx - Matches /shop/a, /shop/a/b, etc.
export default function Shop({
params
}: {
params: { slug: string[] }
}) {
return <div>Path: {params.slug.join('/')}</div>;
}
// app/shop/[[...slug]]/page.tsx - Matches /shop AND /shop/a, /shop/a/b
Group routes without affecting URL:
app/
├── (marketing)/
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
└── (shop)/
└── products/
└── page.tsx # /products
Wrong:
export default function RootLayout({ children }) {
return <div>{children}</div>; // Missing <html> and <body>
}
Correct:
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
next/head in App RouterWrong:
import Head from 'next/head';
export default function Page() {
return (
<>
<Head><title>Title</title></Head>
<main>Content</main>
</>
);
}
Correct:
export const metadata = { title: 'Title' };
export default function Page() {
return <main>Content</main>;
}
After migrating routes, remove the old pages/ directory files to avoid confusion. The build will fail if you have conflicting routes.
page.tsx FilesRoutes are NOT accessible without a page.tsx file. Layouts alone don't create routes.
app/
├── blog/
│ ├── layout.tsx # NOT a route
│ └── page.tsx # This makes /blog accessible
Wrong:
<a href="/about">About</a> // Works but causes full page reload
Correct:
import Link from 'next/link';
<Link href="/about">About</Link> // Client-side navigation
All components in app/ are Server Components by default:
// app/page.tsx - Server Component (default)
export default async function Page() {
const data = await fetch('https://api.example.com/data');
const json = await data.json();
return <div>{json.title}</div>;
}
Benefits:
Use 'use client' directive when you need:
// app/components/Counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // Revalidate every hour
});
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export default async function Page() {
// Fetch in parallel
const [posts, users] = await Promise.all([
fetch('https://api.example.com/posts').then(r => r.json()),
fetch('https://api.example.com/users').then(r => r.json()),
]);
return (/* render */);
}
generateStaticParams is the App Router equivalent of getStaticPaths from the Pages Router. It generates static pages at build time for dynamic routes.
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
// Return array of params to pre-render
return [
{ id: '1' },
{ id: '2' },
{ id: '3' },
];
}
export default function BlogPost({
params
}: {
params: { id: string }
}) {
return <article>Blog post {params.id}</article>;
}
Key Points:
generateStaticParams'use client' directive)getStaticPaths// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params
}: {
params: { slug: string }
}) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
// app/products/[category]/[id]/page.tsx
export async function generateStaticParams() {
const categories = await getCategories();
const params = [];
for (const category of categories) {
const products = await getProducts(category.slug);
for (const product of products) {
params.push({
category: category.slug,
id: product.id,
});
}
}
return params;
}
export default function ProductPage({
params
}: {
params: { category: string; id: string }
}) {
return <div>Category: {params.category}, Product: {params.id}</div>;
}
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
return [{ id: '1' }, { id: '2' }];
}
// Control behavior for non-pre-rendered paths
export const dynamicParams = true; // default - allows runtime generation
// export const dynamicParams = false; // returns 404 for non-pre-rendered paths
export default function BlogPost({
params
}: {
params: { id: string }
}) {
return <article>Post {params.id}</article>;
}
Options:
dynamicParams = true (default): Non-pre-rendered paths generated on-demanddynamicParams = false: Non-pre-rendered paths return 404Pattern 1: Simple ID-based routes
export async function generateStaticParams() {
return [
{ id: '1' },
{ id: '2' },
{ id: '3' },
];
}
Pattern 2: Fetch from API
export async function generateStaticParams() {
const items = await fetch('https://api.example.com/items').then(r => r.json());
return items.map(item => ({ id: item.id }));
}
Pattern 3: Database query
export async function generateStaticParams() {
const posts = await db.post.findMany();
return posts.map(post => ({ slug: post.slug }));
}
Before (Pages Router):
// pages/blog/[id].tsx
export async function getStaticPaths() {
return {
paths: [
{ params: { id: '1' } },
{ params: { id: '2' } },
],
fallback: false,
};
}
export async function getStaticProps({ params }) {
return { props: { id: params.id } };
}
After (App Router):
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
return [
{ id: '1' },
{ id: '2' },
];
}
export const dynamicParams = false; // equivalent to fallback: false
export default function BlogPost({ params }: { params: { id: string } }) {
return <div>Post {params.id}</div>;
}
❌ Wrong: Using 'use client'
'use client'; // ERROR! generateStaticParams only works in Server Components
export async function generateStaticParams() {
return [{ id: '1' }];
}
❌ Wrong: Using Pages Router pattern
export async function getStaticPaths() { // Wrong API!
return { paths: [...], fallback: false };
}
❌ Wrong: Missing export keyword
async function generateStaticParams() { // Must be exported!
return [{ id: '1' }];
}
✅ Correct: Clean Server Component
// app/blog/[id]/page.tsx
// No 'use client' directive
export async function generateStaticParams() {
return [{ id: '1' }, { id: '2' }];
}
export default function Page({ params }: { params: { id: string } }) {
return <div>Post {params.id}</div>;
}
CRITICAL IMPLEMENTATION NOTE:
When asked to "write" or "implement" generateStaticParams:
When migrating or building with App Router, verify:
Structure:
app/ directory existslayout.tsx exists with <html> and <body>page.tsx fileMetadata:
next/head imports in App RouterMetadata typeNavigation:
Link component from next/link<a> tags for internal navigationCleanup:
pages/ directory_app.tsx and _document.tsx removed| Pages Router | App Router | Purpose |
|---|---|---|
pages/index.tsx | app/page.tsx | Home route |
pages/about.tsx | app/about/page.tsx | About route |
pages/[id].tsx | app/[id]/page.tsx | Dynamic route |
pages/_app.tsx | app/layout.tsx | Global layout |
pages/_document.tsx | app/layout.tsx | HTML structure |
pages/api/hello.ts | app/api/hello/route.ts | API route |
# Create new Next.js app with App Router
npx create-next-app@latest my-app
# Run development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
For more advanced routing patterns (parallel routes, intercepting routes, route handlers), refer to the nextjs-advanced-routing skill.
For Server vs Client component best practices and anti-patterns, refer to the nextjs-server-client-components and nextjs-anti-patterns skills.
mindrally/skills
giuseppe-trisciuoglio/developer-kit
syncfusion/react-ui-components-skills
supercent-io/skills-template
binjuhor/shadcn-lar