Every Pages Router pattern mapped to its App Router equivalent. getStaticProps, getServerSideProps, getStaticPaths, ISR—all translated with real before/after examples from my projects.
I migrated my portfolio and Hoop Almanac from Pages Router to App Router. The mental model shift is real, but the patterns map cleanly once you see them side by side. Here's every major pattern translated.
Pages Router: Data fetching happens in special functions (getStaticProps, getServerSideProps). Components receive data as props. Everything is client-side by default.
App Router: Data fetching happens IN the component. Components are server-side by default. You opt INTO client-side with 'use client'.
BEFORE (Pages Router): Export getStaticProps, fetch data, return { props }. The page component receives props. Data is fetched at build time.
AFTER (App Router): Make the component async. Fetch data directly in the component. No special export needed. The component IS the data fetcher.
In my portfolio, the projects page used getStaticProps to fetch from Sanity. Now it's just: async function ProjectsPage() { const projects = await getProjects(); return <ProjectsList projects={projects} />; }. Cleaner, more intuitive.
BEFORE: getServerSideProps runs on every request. No caching. Fresh data each time.
AFTER: Server Components with { cache: 'no-store' } on fetch. Or use the unstable_noStore() function. The component still fetches on every request.
In OpportunIQ, the diagnostic results page needs fresh data (AI responses shouldn't be cached). Before: getServerSideProps fetched the diagnosis. After: The Server Component fetches with { cache: 'no-store' }. Same behavior, simpler code.
BEFORE: Export getStaticPaths with paths array and fallback option. Used with getStaticProps for dynamic routes like /blog/[slug].
AFTER: Export generateStaticParams. Return an array of param objects. Simpler syntax, same result.
My blog uses dynamic routes. Before: getStaticPaths returned { paths: posts.map(p => ({ params: { slug: p.slug } })), fallback: 'blocking' }. After: generateStaticParams returns posts.map(p => ({ slug: p.slug })). The fallback behavior is now controlled by the dynamic segment config.
BEFORE: fallback: false (404 for unknown paths), fallback: true (show loading), fallback: 'blocking' (SSR on demand).
AFTER: export const dynamicParams = true (allow unlisted params, like 'blocking') or false (404 for unlisted). Loading states use loading.tsx.
BEFORE: Return { revalidate: 60 } from getStaticProps. Page regenerates every 60 seconds after a request.
AFTER: Two options. 1) Time-based: export const revalidate = 60 at the page level, or { next: { revalidate: 60 } } on individual fetches. 2) On-demand: Use revalidatePath() or revalidateTag() in Server Actions or Route Handlers.
My portfolio uses on-demand revalidation. When I publish in Sanity, a webhook hits my API route which calls revalidateTag('projects'). The projects page rebuilds instantly—no waiting for a timer.
App Router introduces the cache() function from React. Wrap any async function to memoize it for the duration of a request. Multiple components can call the same cached function without duplicate fetches.
In Hoop Almanac, multiple components need player data: the header shows name, the stats section shows numbers, the predictions section needs history. I wrap getPlayer in cache(). One fetch, multiple consumers.
Pattern: const getPlayer = cache(async (id) => { ... }). Call getPlayer(id) anywhere. React dedupes within the request.
cache() only works within a single request. For caching across requests (like ISR behavior for non-fetch operations), use unstable_cache.
In my portfolio, I cache GraphQL query results: const getCachedProjects = unstable_cache(async () => fetchGraphQL(...), ['projects'], { revalidate: 3600, tags: ['projects'] }). This caches for an hour OR until I call revalidateTag('projects').
BEFORE: useEffect + fetch or SWR/React Query in any component.
AFTER: Same, but the component needs 'use client' directive. Often you can move the fetch to a Server Component and only make the interactive parts client-side.
In Hoop Almanac's live draft, the draft picks need real-time updates (WebSocket). The draft board layout is a Server Component. The live pick feed is a Client Component with useEffect for WebSocket connection. Best of both worlds.
BEFORE: pages/api/contact.ts with export default function handler(req, res).
AFTER: app/api/contact/route.ts with export async function POST(request: Request). Uses Web standard Request/Response. Named exports for HTTP methods (GET, POST, etc.).
The new syntax is cleaner. No more req.method === 'POST' checks. Each method is its own function.
BEFORE: Manual loading states with useState, or fallback: true with router.isFallback.
AFTER: Create loading.tsx next to page.tsx. It shows automatically during navigation. For granular control, wrap components in <Suspense fallback={<Loading />}>.
My portfolio has a loading.tsx that shows a subtle skeleton. Each section can also have its own Suspense boundary for streaming.
BEFORE: Custom _error.tsx page or try/catch in getServerSideProps.
AFTER: Create error.tsx (must be a Client Component). It catches errors in the segment and shows a fallback. Includes a reset() function to retry.
BEFORE: Import Head from 'next/head', add <Head><title>...</title></Head> in component.
AFTER: Export metadata object or generateMetadata function. Type-safe, supports all meta tags, generates Open Graph automatically.
My blog uses generateMetadata to set title, description, and OG image based on the post. Much cleaner than the Head component approach.
getStaticProps → async Server Component
getServerSideProps → Server Component + { cache: 'no-store' }
getStaticPaths → generateStaticParams
revalidate: N → export const revalidate = N or { next: { revalidate: N } }
fallback: 'blocking' → dynamicParams = true
API routes → Route Handlers (GET, POST exports)
useRouter (pages) → useRouter + usePathname + useSearchParams
next/head → metadata export or generateMetadata
_app.tsx → layout.tsx
_document.tsx → layout.tsx (html/body tags)
The patterns map 1:1, but the mental model is inverted. Pages Router: special functions feed data to components. App Router: components ARE the data fetchers. Once that clicks, migration is straightforward. Start with static pages, then tackle dynamic routes, then add interactivity with Client Components where needed.