Skip to main content
Alvin QuachFull Stack Developer
HomeProjectsExperienceBlog
HomeProjectsExperienceBlog
alvinquach

Full Stack Developer building systems that respect complexity.

Open to opportunities

AQ

Projects

  • All Projects
  • Hoparc Physical Therapy
  • OpportunIQ
  • Hoop Almanac
  • SculptQL

Knowledge

  • Blog
  • Experience
  • Interview Prep

Connect

  • Contact
  • LinkedIn
  • GitHub
  • X

Resources

  • Resume
© 2026All rights reserved.
Back to Blogs
Tutorial
Featured
Depth: ●●○○○

Pages Router to App Router: The Complete Migration Cheatsheet

Every Pages Router pattern mapped to its App Router equivalent. getStaticProps, getServerSideProps, getStaticPaths, ISR—all translated with real before/after examples from my projects.

Published January 24, 20265 min readImportance: ★★★★★
Share:

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.

The Big Picture

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'.

getStaticProps → Server Components

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.

getServerSideProps → Server Components + no-store

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.

getStaticPaths → generateStaticParams

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.

fallback: 'blocking' → dynamicParams

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.

ISR (revalidate) → Time-based & On-demand Revalidation

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.

New: The Cache Function

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.

New: unstable_cache for Cross-Request Caching

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').

Client-Side Data → 'use client' + SWR/React Query

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.

API Routes → Route Handlers

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.

Loading States → loading.tsx + Suspense

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.

Error Handling → error.tsx

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.

Head/SEO → Metadata API

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.

Quick Reference Cheatsheet

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)

Key Takeaway

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.