CONTACT US
TABLE OF CONTENTS

Next.js App Router vs Page Router Comparison

Introduction: From Pages Router to App Router

If you’ve ever worked as a frontend developer, you’ve likely experienced how quickly the tools we work with evolve. Next.js is no exception. It underwent a significant transformation with the introduction of the App Router.

Today Next.js supports both the Pages Router and the App Router, letting developers choose their routing approach freely. To understand the details of this change, its benefits, and the challenges it may present for experienced developers creating Next.js apps, we should first look at why the App Router was implemented in the first place.

Why Next.js Introduced the App Router

Next.js started as a Pages Router-based framework with a goal to simplify the world of web development. However, with the development of React and the emergence of React Server Components (RSC), the Vercel team saw a need for a fundamental change in their approach to routing. Thus the new App Router was created to answer a few problems the developers at the time were facing:

  1. The need for a better server-first paradigm. Traditional React applications often render on the client first, which can lead to delays in content display. App Router enables the natural utilization of server-side rendering capabilities.
  2. More granular caching mechanisms. Using the Pages Router offered options like SSR, SSG, and ISR, but Next.js App Router provides more control over caching at the component and fetch request level.
  3. Streaming capabilities. Indtroducing this function in App Router allowed parts of the page to be sent to the client as they become available.
  4. Better developer experience. Teams can create great applications with layouts, loading states, and error boundaries built into the routing system.

Now,  imagine you’re building an e-commerce product page that shows product details, user reviews, related products, and personalized recommendations. With Pages Router, you might have code like this:

// pages/product/[id].js in Pages Router

export const getServerSideProps = async ({ params }) => {

  const { id } = params;

 

  const product = await fetchProductDetails(id);

  const reviews = await fetchProductReviews(id);

  const related = await fetchRelatedProducts(id);

 

  const recommendations = await fetchPersonalizedRecommendations();

 

  return {

    props: {

      product,

      reviews,

      related,

      recommendations,

    },

  };

};

 

const ProductPage = ({ product, reviews, related, recommendations }) => (

  <div>

    <ProductDetails product={product} />

    <Reviews reviews={reviews} />

    <RelatedProducts products={related} />

    <Recommendations items={recommendations} />

  </div>

);

 

export default ProductPage;

What’s the problem? The user sees nothing until ALL data is fetched, even though the product details could be shown immediately.

With App Router, the same page could be implemented like this:

// app/product/[id]/page.js in App Router

const ProductPage = async ({ params }) => {

  const { id } = await params;

 

  const productData = await fetchProductDetails(id);

 

  return (

    <div>

      <ProductDetails product={productData} />

      <Suspense fallback={<ReviewsSkeleton />}>

        <Reviews productId={id} />

      </Suspense>

      <Suspense fallback={<RelatedProductsSkeleton />}>

        <RelatedProducts productId={id} />

      </Suspense>

      <Suspense fallback={<RecommendationsSkeleton />}>

        <Recommendations />

      </Suspense>

    </div>

  );

}

In this example, the product details are rendered immediately, while the reviews, related products, and recommendations stream in as they become available, improving the perceived performance.

Features in App Router

The App Router in Next.js introduces features like server components, improved data fetching, and enhanced flexibility. As a result, it offers a more scalable and efficient way to build modern applications thanks to built-in support for layouts, streaming, and Server Actions.

React Server Components

The cornerstone of App Router is React Server Components, which completely changes how we think about rendering: 

  • Server Components run exclusively on the server and never ship to the client, eliminating the need to send unnecessary JavaScript to the browser
  • Client Components (marked with ‘use client’) run on both server (for initial HTML) and client (for hydration)
  • Data fetching happens on the server by default, meaning data on the server is processed before rendering. This reduces client-side JavaScript and improves performance.

A common mistake during migration is placing the ‘use client’ directive too high in the component tree. Why is it a mistake? Well, it removes many of the benefits of Server Components

Instead, try pushing the client boundary as far down as possible, keeping most of your UI as Server Components. For example, consider a product card component:

"use client";

 

import { useState } from "react";

import Image from "next/image";

import { formatPrice } from "@/utils/formatters";

 

const ProductCard = ({ product: { image, name, price } }) => {

  const [isWishlisted, setIsWishlisted] = useState(false);

 

  return (

    <div className="product-card">

      <Image src={image} alt={name} width={200} height={200} />

      <h3>{name}</h3>

      <p>{formatPrice(price)}</p>

      <button onClick={() => setIsWishlisted((prev) => !prev)}>

        {isWishlisted ? "♥" : "♡"}

      </button>

    </div>

  );

};

 

export default ProductCard;

A better approach would be:

// ProductCard.js (Server Component)

import Image from "next/image";

import { formatPrice } from "@/utils/formatters";

import WishlistButton from "./WishlistButton";

 

const ProductCard = ({ product: { image, name, price, id } }) => (

  <div className="product-card">

    <Image src={image} alt={name} width={200} height={200} />

    <h3>{name}</h3>

    <p>{formatPrice(price)}</p>

    <WishlistButton productId={id} />

  </div>

);

 

export default ProductCard;

 

// WishlistButton.js (Client Component)

"use client";

 

import { useState } from "react";

 

const WishlistButton = ({ productId }) => {

  const [isWishlisted, setIsWishlisted] = useState(false);

 

  return (

    <button onClick={() => setIsWishlisted((prev) => !prev)}>

      {isWishlisted ? "♥" : "♡"}

    </button>

  );

};

 

export default WishlistButton;

This keeps most of your UI as Server Components, only moving the interactive parts to Client Components and sending less JavaScript to the browser as a result.

Loading Strategies & Streaming

One of the most powerful features of App Router is its built-in loading states through loading.tsx files. These use React Suspense under the hood to allow for streaming and improve page load times.

When a user navigates to a route, Next.js can immediately show a loading UI while the page content is being prepared on the server. This improves perceived performance by providing immediate feedback to the user.

For example:

// app/dashboard/loading.tsx

const DashboardLoading = () => (

  <div className="dashboard-skeleton">

    <div className="header-skeleton animate-pulse"></div>

    <div className="metrics-skeleton animate-pulse"></div>

    <div className="chart-skeleton animate-pulse"></div>

  </div>

);

 

// app/dashboard/page.tsx

import { Suspense } from "react";

import DashboardHeader from "./components/DashboardHeader";

import Metrics from "./components/Metrics";

import RevenueChart from "./components/RevenueChart";

import CustomerTable from "./components/CustomerTable";

 

const Dashboard = () => (

  <div className="dashboard">

    {/* These components will render as soon as their data is ready */}

    <DashboardHeader />

    <Metrics />

    <RevenueChart />

    {/* For particularly heavy components, we can add an additional Suspense boundary */}

    <Suspense fallback={<div className="table-skeleton animate-pulse"></div>}>

      <CustomerTable />

    </Suspense>

  </div>

);

The Dashboard page will show a loading skeleton immediately, and then stream in the actual components as they become ready. The CustomerTable, which might be particularly data-heavy, has its own Suspense boundary for more granular loading.

Development with Next.js is Easier than you think

Data Fetching in App Router

Data fetching in App Router is very different from Pages Router:

  • The basic API of fetch() is improved with automatic request deduplication and caching, increasing speed and efficiency in data fetching.
  • You can control cache behavior with next.revalidate or by setting cache options in fetch, determining whether data should be fetched from the server or during build to optimize performance.
  • Server Actions provide a way to mutate data directly from the server.
  • Client Components can still fetch data, but doing so after the initial page load typically results in better performance metrics.

Here’s how data fetching looks in App Router:

// app/products/page.js

import FeaturedProducts from "./components/FeaturedProducts";

import ProductGrid from "./components/ProductGrid";

 

const ProductsPage = async () => {

  const products = await fetch("https://api.example.com/products").then((res) =>

    res.json()

  );

 

  // This fetch will revalidate every 60 seconds

  const featuredProducts = await fetch(

    "https://api.example.com/featured-products",

    {

      next: { revalidate: 60 },

    }

  ).then((res) => res.json());

 

  // This fetch will never be cached

  const inventory = await fetch("https://api.example.com/inventory", {

    cache: "no-store",

  }).then((res) => res.json());

 

  return (

    <div>

      <FeaturedProducts products={featuredProducts} />

      <ProductGrid products={products} inventory={inventory} />

    </div>

  );

};

 

export default ProductsPage;

For data mutations, you can use Server Actions (which are essentially server-side functions that can be called from the client):

​​// app/products/actions.js

"use server";

 

export const addToCart = async (productId, quantity) => {

  // Server-side logic to add product to cart

  const result = await db.carts.update({

    where: { userId: session.user.id },

    data: {

      items: {

        create: { productId, quantity },

      },

    },

  });

 

  return result;

};

 

// app/products/AddToCartButton.js

"use client";

 

import { useTransition } from "react";

import { addToCart } from "./actions";

 

const AddToCartButton = ({ productId }) => {

  const [isPending, startTransition] = useTransition();

 

  return (

    <button

      disabled={isPending}

      onClick={() => startTransition(() => addToCart(productId, 1))}

    >

      {isPending ? "Adding..." : "Add to Cart"}

    </button>

  );

};

 

export default AddToCartButton;

This way sensitive operations are kept on the server while still providing a responsive UI.

File Structure & Routing Differences

App Router has an intuitive file-based routing system. It lets developers create a folder structure that organizes routes, layouts, and components efficiently and includes support for parallel routes. Multiple sections of a page can now load independently in the directory:

  • page.tsx defines a route (similar to pages/route.tsx in Pages Router)
  • layout.tsx creates a shared UI that wraps multiple pages
  • loading.tsx provides a loading UI for the route
  • error.tsx catches and displays errors within the route
  • Route groups (folders starting with parentheses) help organize code without affecting the URL
  • Dynamic segments (folders with square brackets) work similarly to Pages Router but with more flexibility

Here’s how a file structure showing these conventions and folder names can look like:

Important concepts:

  • Route Groups – Folders in parentheses like (marketing) don’t affect URL paths [^1]
  • Private Folders – Folders starting with underscore like _components aren’t included in routing [^2]
  • Dynamic Routes – Folders in brackets like [teamId] create dynamic segments
  • Special Fileslayout.tsx, page.tsx, loading.tsx, error.tsx, etc.

AMPLIENCE – GET STARTED GUIDE

Building an Interactive Guide Demystifying CMS’s Capabilities with Next.js

Read Case Study
Amplience Case Study - Outsourcing with Pagepro

Comparing the Pages Directory and App Directory

Although App Router might represent the future of Next.js, Pages Router can still be useful in certain scenarios:

  • Working with tools that rely heavily on getServerSideProps or getStaticProps
  • You need a simpler, full-page SSR without the added complexity of nested layouts
  • Your team is more comfortable with the client-first approach

You can even mix both routers in the same project, using Pages Router for API routes while using App Router for UI components.

Let’s compare implementing the same feature in both routers.

Pages Router approach for a blog post page:

// pages/blog/[slug].js

 

const BlogPost = ({ post, relatedPosts }) => (

  <div>

    <Head>

      <title>{post.title}</title>

      <meta name="description" content={post.excerpt} />

    </Head>

    <Header />

    <main>

      <article>

        <h1>{post.title}</h1>

        {/* NOTE: When using dangerouslySetInnerHTML, make sure to sanitize the content */}

        <div dangerouslySetInnerHTML={{ __html: post.content }} />

      </article>

      <RelatedPosts posts={relatedPosts} />

    </main>

    <Footer />

  </div>

);

 

export const getStaticPaths = async () => {

  const posts = await getAllPosts();

 

  return {

    paths: posts.map((post) => ({ params: { slug: post.slug } })),

    fallback: "blocking",

  };

};

 

export const getStaticProps = async ({ params }) => {

  const post = await getPostBySlug(params.slug);

  const relatedPosts = await getRelatedPosts(post.id);

 

  return {

    props: { post, relatedPosts },

    revalidate: 3600, // Revalidate every hour

  };

};

 

export default BlogPost;

App Router approach for the same page:

// app/blog/[slug]/page.js

const BlogPost = async ({ params }) => {

  const post = await getPostBySlug(params.slug);

 

  return (

    <article>

      <h1>{post.title}</h1>

      {/* NOTE: When using dangerouslySetInnerHTML, make sure to sanitize the content */}

      <div dangerouslySetInnerHTML={{ __html: post.content }} />

      <Suspense fallback={<RelatedPostsSkeleton />}>

        <RelatedPosts postId={post.id} />

      </Suspense>

    </article>

  );

};

 

export const generateStaticParams = async () => {

  const posts = await getAllPosts();

 

  return posts.map((post) => ({

    slug: post.slug,

  }));

};

 

export const generateMetadata = async ({ params }) => {

  const post = await getPostBySlug(params.slug);

 

  return {

    title: post.title,

    description: post.excerpt,

  };

};

 

export default BlogPost;

 

// app/blog/[slug]/components/RelatedPosts.js

const RelatedPosts = async ({ postId }) => {

  const relatedPosts = await getRelatedPosts(postId);

 

  return (

    <section>

      <h2>Related Posts</h2>

      <ul>

        {relatedPosts.map((post) => (

          <li key={post.id}>

            <Link href={`/blog/${post.slug}`}>{post.title}</Link>

          </li>

        ))}

      </ul>

    </section>

  );

};

 

export default RelatedPosts;

 

// app/blog/layout.js

const BlogLayout = ({ children }) => (

  <>

    <Header />

    <main>{children}</main>

    <Footer />

  </>

);

 

export default BlogLayout;

Notice how the App Router:

  • Separates layouts from pages
  • Uses generateMetadata instead of <Head>
  • Streams in related posts separately
  • Handles data fetching directly in the components that need it

Best Practices for Using the App Router in Next.js

Authentication Patterns

Authentication libraries like NextAuth.js work differently in App Router. Session management requires careful consideration of the server/client boundary, and securing API calls needs a different approach when using Server Components.

For example, with NextAuth.js in App Router:

// app/api/auth/[...nextauth]/route.js

import NextAuth from "next-auth";

import { options } from "./options";

 

const handler = NextAuth(options);

export { handler as GET, handler as POST };

 

// app/components/auth-provider.js

"use client";

 

import { SessionProvider } from "next-auth/react";

 

const AuthProvider = ({ children }) => (

  <SessionProvider>{children}</SessionProvider>

);

 

export default AuthProvider;

 

// app/layout.js

import AuthProvider from "./components/auth-provider";

 

const RootLayout = async ({ children }) => (

  <html>

    <body>

      <AuthProvider>{children}</AuthProvider>

    </body>

  </html>

);

 

export default RootLayout;

 

// app/profile/page.js

import { getServerSession } from "next-auth";

import { options } from "../api/auth/[...nextauth]/options";

import { redirect } from "next/navigation";

 

const ProfilePage = async () => {

  const session = await getServerSession(options);

 

  if (!session) {

    redirect("/auth/signin");

  }

 

  return <div>Welcome, {session.user.name}!</div>;

};

 

export default ProfilePage;

SEO Concerns

App Router offers a better metadata API compared to the getStaticProps approach in Pages Router. It gives you more flexibility for SEO optimization by generating dynamic metadata based on route parameters or fetched data:

// app/blog/[slug]/page.js

export const generateMetadata = async ({ params }) => {

  const post = await getPostBySlug(await params.slug);

 

  return {

    title: post.title,

    description: post.excerpt,

    openGraph: {

      title: post.title,

      description: post.excerpt,

      images: [{ url: post.featuredImage }],

      type: "article",

      publishedTime: post.publishedAt,

      authors: [post.author.name],

    },

    twitter: {

      card: "summary_large_image",

      title: post.title,

      description: post.excerpt,

      images: [post.featuredImage],

    },

  };

};

This method is much more flexible than the <Head> component or getStaticProps metadata in Pages Router, since it allows for fetching and generating metadata on demand.

Debugging Real-World Issues

Routing in Next.js can pose problems like caching issues, incorrect route nesting, and hydration errors. Some of the issues you might encounter include:

1. Unexpected Caching Behavior

If you’re seeing stale data, it’s often due to a misunderstanding of how fetch caching works:

//This will be cached indefinitely by default

const data = await fetch("https://api.example.com/data");

 

//To disable caching:

const freshData = await fetch("https://api.example.com/data", {

  cache: "no-store",

});

 

//To revalidate periodically:

const revalidatedData = await fetch("https://api.example.com/data", {

  next: { revalidate: 60 }, // revalidate every 60 seconds

});

2. Nested Layouts Not Updating

If you have dynamic data in layouts that aren’t updating, remember that layouts are cached more aggressively than pages. Consider moving dynamic content to the page component instead, or use a Client Component with client-side data fetching for truly dynamic parts of layouts.

3. Hydration Errors

These often occur when server and client rendering don’t match.

// This will cause hydration errors

const Component = () => {

  // This generates different output on server vs client

  const now = new Date().toLocaleTimeString();

 

  return <div>Current time: {now}</div>;

};

 

// Fix: Use useEffect for client-side updates

"use client";

 

import { useState, useEffect } from "react";

 

const Component = () => {

  const [time, setTime] = useState("");

 

  useEffect(() => {

    setTime(new Date().toLocaleTimeString());

  }, []);

 

  return <div>Current time: {time}</div>;

};

4. Performance Bottlenecks

Server Components can create performance bottlenecks if they fetch too much data. 

//Inefficient approach:

const ProductsPage = async () => {




//This fetches ALL products, potentially thousands:

  const allProducts = await db.products.findMany();

 

  return (

    <div>

      {/* Filtering on the client */}

      {allProducts

        .filter((p) => p.category === "electronics")

        .map((product) => (

          <ProductCard key={product.id} product={product} />

        ))}

    </div>

  );

};

 

// Better approach

const ProductsPageBetter = async () => {

  // Only fetch what we need

  const electronics = await db.products.findMany({

    where: { category: "electronics" },

    take: 20,

  });

 

  return (

    <div>

      {electronics.map((product) => (

        <ProductCard key={product.id} product={product} />

      ))}

    </div>

  );

};

Advanced Features and Performance Optimization

App Router can significantly improve Core Web Vitals metrics when used correctly. Time to First Byte (TTFB) can be faster with proper streaming implementation, First Contentful Paint (FCP) often improves due to server rendering, while Largest Contentful Paint (LCP) benefits from prioritized content streaming

However, these improvements aren’t automatic. You’ll need to carefully structure your application, place client/server boundaries strategically, and implement proper caching strategies.

To optimize LCP in a product page:

// app/product/[id]/page.js

import { Suspense } from "react";

import ProductDetails from "./components/ProductDetails";

import ProductImage from "./components/ProductImage";

import RelatedProducts from "./components/RelatedProducts";

import Reviews from "./components/Reviews";

 

const ProductPage = async ({ params }) => {

  const { id } = await params; // Destructure id from params

  // Prioritize fetching the critical LCP element (product image)

  const product = await getProductBasicInfo(id);

 

  return (

    <div className="product-page">

      {/* LCP element - rendered first */}

      <ProductImage image={product.image} name={product.name} />

 

      {/* Important but not LCP */}

      <ProductDetails product={product} />

 

      {/* Less critical elements - streamed in */}

      <Suspense

        fallback={<div className="loading-skeleton">Loading related...</div>}

      >

        <RelatedProducts productId={id} />

      </Suspense>

 

      <Suspense

        fallback={<div className="loading-skeleton">Loading reviews...</div>}

      >

        <Reviews productId={id} />

      </Suspense>

    </div>

  );

};

 

export default ProductPage;

 

// components/ProductImage.js - Optimized for LCP

import Image from "next/image";

 

const ProductImage = ({ image, name }) => (

  <div className="product-image-container">

    <Image

      src={image.url}

      alt={name}

      width={600}

      height={600}

      priority // This tells Next.js this is an LCP element

      quality={90}

    />

  </div>

);

 

export default ProductImage;

For practical performance benchmarking, you can use tools like Next.js built-in analytics (when deployed on Vercel), Chrome DevTools Performance panel, Lighthouse in CI/CD pipelines, Core Web Vitals monitoring via tools like SpeedCurve or WebPageTest.

The Future of Next.js Routing

What can we expect from App Router in the future? Continued refinements to the API, better developer tooling, and deeper integration with the React ecosystem as Server Components mature.

We could also see:

  • Improved data mutation patterns beyond Server Actions.
  • Better debugging tools for RSC and streaming.
  • More fine-grained control over caching behaviors.
  • Expanded capabilities for middleware.
  • Better integration with Vercel’s Edge network.

Adopting App Router might mean embracing a more server-centric mindset, which may require some getting used to for developers. Ultimately this switch can lead to better performance and user experience.

Using Next.js and the App Router

Next.js evolution represents a new approach to how we build Next.js applications. Embracing React Server Components, streaming, and granular caching, helps Next.js position itself at the forefront of modern web development. 

The App Router’s appearance changed Next.js into an even more powerful tool for building high-performance web applications. Though the transition means learning new patterns or even rethinking application architecture, the benefits in terms of performance, developer experience, and user experience are huge. Understanding the principles behind this evolution will help you decide on your Next.js routing strategy.  

If you want to get more details on the transition from the Pages Router to the App Router in Next.js, explore the official Next.js documentation and GitHub repository for real-world examples and contributions.

Need help with your Next.js app?

Read More

Sources

Article link copied

Close button

Leave a Reply

* Required informations.