WEIRDSOFT
Back to blog

Why We Chose Next.js App Router

After five production applications built on Next.js App Router — ranging from high-traffic e-commerce platforms to real-time analytics dashboards — we have accumulated enough experience to give an honest assessment. Not the marketing version, but the developer-facing reality: where App Router transforms your architecture for the better, and where it introduces complexity that demands careful management.

This post covers the concrete benefits of Server Components, nested layout patterns, streaming strategies, data fetching architecture, search params handling, middleware, ISR with on-demand revalidation, and a practical migration strategy from Pages Router.

Server Components: Beyond the Hype

React Server Components (RSC) are the defining feature of the App Router. They eliminate the client-side JavaScript tax by rendering components exclusively on the server. For content-heavy pages, this is transformative — zero bytes of JavaScript for the initial view, zero hydration cost, zero client-side data fetching waterfalls.

What You Actually Gain

The headline numbers from our e-commerce deployment tell the story:

Metric Pages Router (before) App Router (after) Improvement
Total JS (product page) 187 KB 42 KB 77% reduction
Largest Contentful Paint 2.4 s 1.1 s 54% improvement
Time to Interactive 3.8 s 1.3 s 66% improvement
First Input Delay 85 ms 12 ms 86% improvement
Lighthouse Performance 68 97 +29 points

These gains come from a single architectural change: moving data fetching and rendering to the server.

The Server/Client Boundary Discipline

The biggest cognitive shift with App Router is learning where the boundary between server and client lives. Every component is a Server Component by default. You opt into client interactivity with 'use client':

// This is a Server Component by default
// It can be async, access databases, read files, etc.
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({
    where: { id: params.id },
    include: { reviews: true, images: true },
  });

  if (!product) notFound();

  return (
    <div>
      <ProductImages images={product.images} />
      <ProductInfo product={product} />

      {/* Only this wrapper needs to be a Client Component */}
      <Suspense fallback={<ReviewSkeleton />}>
        <ReviewSection productId={product.id} />
      </Suspense>
    </div>
  );
}
'use client';

// This is a Client Component with interactivity
export function AddToCartButton({ productId, variantId }: Props) {
  const [isAdding, setIsAdding] = useState(false);

  const handleAdd = async () => {
    setIsAdding(true);
    await addToCart(productId, variantId);
    setIsAdding(false);
  };

  return (
    <button onClick={handleAdd} disabled={isAdding}>
      {isAdding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

The discipline is to push the 'use client' boundary as far toward the leaves of your component tree as possible. If only the AddToCartButton needs interactivity, only that file should be a Client Component. Everything else — data fetching, layout, rendering — stays on the server.

Avoiding the Client Component Creep

The most common mistake teams make is slapping 'use client' on a layout or page file too early, which forces all children to be client-rendered even if they do not need interactivity. This negates the core benefit of RSC.

We enforce this with an ESLint rule:

// .eslintrc.json
{
  "rules": {
    "@next/next/no-server-import-in-client": "error",
    "no-restricted-imports": ["error", {
      "paths": [{
        "name": "next/navigation",
        "message": "Use only in client components. Server components should use server primitives."
      }]
    }]
  }
}

Nested Layouts: The Architecture You Actually Want

Layout nesting is not a new concept in web frameworks, but App Router's implementation is the first we have used that maps cleanly to real application architecture.

Persistent Layouts Without Context

In Pages Router, persistent layouts required either a custom _app.tsx hack with getLayout or a React context provider. In App Router, layouts are built into the filesystem:

// app/layout.tsx — Root layout, wraps every page
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>
          <Header />
          <main>{children}</main>
          <Footer />
        </Providers>
      </body>
    </html>
  );
}
// app/dashboard/layout.tsx — Dashboard layout, only wraps dashboard pages
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <section className="grid grid-cols-[250px_1fr]">
      <DashboardNav />
      <div className="p-6">{children}</div>
    </section>
  );
}
// app/dashboard/settings/layout.tsx — Settings sub-layout
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="max-w-3xl mx-auto">
      <SettingsTabs />
      {children}
    </div>
  );
}

The key behaviors that make this powerful:

  • Layouts persist across navigations. Navigating from /dashboard/settings/profile to /dashboard/settings/notifications only re-renders the page content. The DashboardLayout and SettingsLayout are preserved. No React state is lost, no re-fetching of navigation data occurs.
  • Layouts can fetch their own data. Each layout can independently fetch data without passing props through the tree. The dashboard sidebar can fetch the user's teams and projects independently of the page content.
  • Layouts are Server Components by default. They can access databases, read cookies, and fetch data without shipping any JS to the client.

Data Co-location in Layouts

Each layout can run its own parallel data fetches:

// app/dashboard/layout.tsx
import { getServerSession } from '@/lib/auth';
import { getTeams } from '@/lib/teams';

export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
  // These fetches run in parallel
  const [session, teams] = await Promise.all([
    getServerSession(),
    getTeams(),
  ]);

  if (!session) redirect('/login');

  return (
    <section className="grid grid-cols-[250px_1fr]">
      <DashboardNav teams={teams} user={session.user} />
      <div className="p-6">{children}</div>
    </section>
  );
}

This eliminates the "who fetches the data" question that plagued Pages Router applications. Every segment of the UI owns its data requirements. No prop drilling. No context-based caching layers for shared data.

Streaming with loading.tsx

Streaming HTML allows the server to send content progressively rather than waiting for every data dependency to resolve. In App Router, loading.tsx is the streaming boundary.

How It Works

When a page or layout starts loading, Next.js immediately sends the loading.tsx fallback for that segment along with the surrounding layout HTML. As async data resolves, the real content streams in and replaces the fallback:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="space-y-4 p-6 animate-pulse">
      <div className="h-8 w-48 bg-gray-200 rounded" />
      <div className="grid grid-cols-3 gap-4">
        <div className="h-32 bg-gray-200 rounded" />
        <div className="h-32 bg-gray-200 rounded" />
        <div className="h-32 bg-gray-200 rounded" />
      </div>
      <div className="h-64 bg-gray-200 rounded" />
    </div>
  );
}

The critical insight is that loading.tsx applies to a route segment and all its children. If you want more granular streaming, use <Suspense> boundaries within the page:

// app/reports/page.tsx
import { Suspense } from 'react';

export default function ReportsPage() {
  return (
    <div className="space-y-8">
      <PageHeader />
      <Suspense fallback={<ChartSkeleton />}>
        <SlowChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <SlowDataTable />
      </Suspense>
      <Suspense fallback={<p>Loading summary...</p>}>
        <QuickSummary />
      </Suspense>
    </div>
  );
}

In this example:

  • QuickSummary (fast query) renders almost immediately
  • SlowChart (complex aggregation) streams in when ready
  • SlowDataTable streams independently
  • The page header renders instantly because it has no async dependencies

Skeleton Design Principles

Effective skeletons communicate what the page will look like, reducing the perception of load time. Follow these principles:

  1. Match layout structure: Skeleton dimensions should match the real content dimensions
  2. Avoid layout shift: Use fixed-height containers for skeleton placeholders
  3. Animate subtly: A gentle pulse animation signals activity without being distracting
  4. Prioritize above-the-fold: Stream critical content first, defer secondary content

Data Fetching Patterns

App Router's approach to data fetching unifies server-side data access with a powerful caching model. The patterns that emerge are different from anything in Pages Router.

Parallel Data Fetching

When a page needs multiple independent data sources, fetch them in parallel:

// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  // These run in parallel on the server
  const [product, reviews, relatedProducts, seller] = await Promise.all([
    getProduct(params.id),
    getReviews(params.id),
    getRelatedProducts(params.id),
    getSeller(params.id),  // Extracted from product data
  ]);

  return (
    <div>
      <ProductDetail product={product} />
      <ReviewsList reviews={reviews} />
      <RelatedProducts products={relatedProducts} />
      <SellerInfo seller={seller} />
    </div>
  );
}

If some data is less critical, defer it with <Suspense>:

export default async function ProductPage({ params }: { params: { id: string } }) {
  // Critical data: block the page
  const product = await getProduct(params.id);

  return (
    <div>
      <ProductDetail product={product} />
      {/* Less critical: stream in */}
      <Suspense fallback={<ReviewSkeleton />}>
        <ReviewsSection productId={params.id} />
      </Suspense>
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedSection productId={params.id} />
      </Suspense>
    </div>
  );
}

async function ReviewsSection({ productId }: { productId: string }) {
  const reviews = await getReviews(productId);
  return <ReviewsList reviews={reviews} />;
}

async function RelatedSection({ productId }: { productId: string }) {
  const related = await getRelatedProducts(productId);
  return <RelatedProducts products={related} />;
}

Sequential Data Fetching

Sometimes data depends on previous fetches. The key is to keep sequential chains short:

async function AuthorPosts({ authorId }: { authorId: string }) {
  // Only one sequential chain: get author, then get their posts
  const author = await getAuthor(authorId);
  const posts = await getPostsByAuthor(author.id);

  return (
    <div>
      <AuthorHeader author={author} />
      <PostList posts={posts} />
    </div>
  );
}

Caching Strategy

App Router introduces the fetch cache with a declarative API:

// Force dynamic — always fetch fresh data
fetch(url, { cache: 'no-store' });

// Time-based revalidation — fetch fresh after 1 hour
fetch(url, { next: { revalidate: 3600 } });

// Force static — fetch once at build time
fetch(url, { cache: 'force-cache' });

For database calls that do not use fetch, wrap with React.cache to deduplicate within a request:

import { cache } from 'react';
import { db } from '@/lib/db';

export const getProduct = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { images: true, category: true },
  });
});

React.cache ensures that if getProduct('123') is called in layout and page during the same request, the database query runs only once. This is automatic deduplication without request-level memoization libraries.

Search Params Handling

Search parameters in App Router are deliberately server-side. This is more verbose than Pages Router's useRouter but enables better performance and SEO.

The Search Params Pattern

// app/products/page.tsx
interface ProductsPageProps {
  searchParams: Promise<{
    q?: string;
    category?: string;
    page?: string;
    sort?: string;
  }>;
}

export default async function ProductsPage({ searchParams }: ProductsPageProps) {
  // searchParams is a Promise in React 19 / Next.js 15+
  const params = await searchParams;

  const page = Number(params.page) || 1;
  const query = params.q || '';
  const category = params.category || 'all';
  const sort = params.sort || 'newest';

  const { products, totalPages } = await getProducts({
    query,
    category,
    page,
    sort,
  });

  return (
    <div>
      <ProductFilters currentCategory={category} currentSort={sort} />
      <ProductGrid products={products} />
      <Pagination currentPage={page} totalPages={totalPages} />
    </div>
  );
}

Client-Side Search Param Updates

For interactive filtering without page navigation, use useSearchParams and useRouter on the client:

'use client';

import { useSearchParams, useRouter, usePathname } from 'next/navigation';

export function ProductFilters({ currentCategory, currentSort }: Props) {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();

  const updateFilter = (key: string, value: string) => {
    const params = new URLSearchParams(searchParams.toString());
    if (value) {
      params.set(key, value);
    } else {
      params.delete(key);
    }
    // Reset to page 1 when filters change
    params.delete('page');
    router.push(`${pathname}?${params.toString()}`);
  };

  return (
    <div className="flex gap-4">
      <select
        value={currentCategory}
        onChange={(e) => updateFilter('category', e.target.value)}
      >
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>
      <select
        value={currentSort}
        onChange={(e) => updateFilter('sort', e.target.value)}
      >
        <option value="newest">Newest</option>
        <option value="price-asc">Price: Low to High</option>
        <option value="price-desc">Price: High to Low</option>
      </select>
    </div>
  );
}

Search Params Best Practices

  • Validate and sanitize on the server: Never trust raw search params. Cast to expected types, provide sensible defaults, and reject invalid values.
  • Use URLSearchParams for serialization: Build search param strings with the standard URLSearchParams API rather than manual string concatenation.
  • Reset pagination on filter changes: When a user changes a filter, reset the page to 1. Failing to do this causes confusing empty states.
  • Preserve unknown params: When updating a single param, preserve the rest with URLSearchParams(searchParams.toString()).

Middleware Use Cases

Middleware runs before every request, giving you a hook for redirects, rewrites, authentication checks, and A/B testing.

Authentication Gate

The most common middleware use case: protect routes based on session state:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });

  // Redirect unauthenticated users to login
  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Role-based access control
  if (request.nextUrl.pathname.startsWith('/admin') && token.role !== 'admin') {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*', '/account/:path*'],
};

A/B Testing with Cookies

import { NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Assign experiment group if not already assigned
  if (!request.cookies.has('experiment-pdp-v2')) {
    const variant = Math.random() < 0.5 ? 'control' : 'treatment';
    response.cookies.set('experiment-pdp-v2', variant, {
      maxAge: 60 * 60 * 24 * 30, // 30 days
      path: '/',
    });
  }

  return response;
}

Then read the cookie in your Server Component:

import { cookies } from 'next/headers';

export default async function ProductPage({ params }: Props) {
  const cookieStore = await cookies();
  const variant = cookieStore.get('experiment-pdp-v2')?.value || 'control';

  return variant === 'treatment' ? <ProductPageV2 /> : <ProductPageV1 />;
}

Middleware Performance Considerations

Middleware runs on every matching request. Keep it lightweight — avoid external API calls, heavy computation, or large library imports. If you need async operations, cache aggressively:

// Bad — slow middleware
const geoData = await fetchGeoAPI(request); // Blocks every request

// Good — use edge-compatible, in-memory lookups
const geoData = getGeoFromCF(request.cf); // Fast, no network calls

ISR and On-Demand Revalidation

Incremental Static Regeneration (ISR) was already powerful in Pages Router, but App Router makes it more flexible with on-demand revalidation via API routes.

Time-Based ISR

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 300 }, // Revalidate every 5 minutes
  }).then((res) => res.json());

  return <ProductGrid products={products} />;
}

On-Demand Revalidation

For content that changes unpredictably (CMS updates, inventory changes), use on-demand revalidation:

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidation-secret');

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const body = await request.json();
  const { tag } = body;

  // Revalidate all cached data with this tag
  revalidateTag(tag);

  return NextResponse.json({ revalidated: true, tag });
}

Then tag your fetches:

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetch(
    `https://cms.example.com/api/products/${params.id}`,
    { next: { tags: [`product-${params.id}`, 'products'] } }
  ).then((res) => res.json());

  return <ProductDetail product={product} />;
}

When the CMS webhook calls /api/revalidate with tag: "product-123", only that product's cache is invalidated. The rest of the product catalog remains cached. This is surgical cache invalidation.

ISR Strategy Decision Matrix

Content Type Update Frequency Strategy
Blog posts Daily Time-based ISR (3600s)
Product pages On-demand (CMS webhook) Tag-based revalidation
User dashboards Per-request Dynamic rendering
Marketing pages Weekly Static generation (build time)
Inventory data Real-time Client-side fetch with stale-while-revalidate

Migration Strategy from Pages Router

Migrating a production Pages Router application to App Router is a significant undertaking. A naive approach — rewrite every page at once — is risky and disruptive. A phased migration is safer and more sustainable.

Phase 1: Infrastructure (Week 1)

Set up the App Router alongside your existing Pages Router:

// next.config.js — Both routers can coexist
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Existing Pages Router config stays
};

module.exports = nextConfig;

Add root app/layout.tsx and configure Providers, fonts, and metadata that were previously in _app.tsx and _document.tsx:

// app/layout.tsx
import { Inter } from 'next/font/google';
import { Providers } from '@/components/providers';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: { default: 'My App', template: '%s | My App' },
  description: 'Description',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.className}>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Phase 2: Low-Risk Pages (Weeks 2-3)

Migrate pages that have minimal interactivity — marketing pages, blog posts, documentation:

  • Move pages/about.tsx to app/about/page.tsx
  • Convert getStaticProps to async Server Component data fetching
  • Replace next/head with the metadata export
  • Replace next/image with the new <Image> (no DOM changes needed)

Phase 3: Data-Driven Pages (Weeks 4-6)

Migrate pages with complex data dependencies:

  • Convert getServerSideProps to inline async data fetching in Server Components
  • Split monolithic page fetches into parallel Promise.all calls
  • Add <Suspense> boundaries for streaming
  • Add loading.tsx for route-level loading states
  • Replace useEffect data fetching with Server Component patterns

Phase 4: Interactive Pages (Weeks 7-8)

Migrate pages with heavy client interactivity — dashboards, forms, admin panels:

  • Identify the minimal 'use client' boundaries
  • Extract interactive islands into Client Components
  • Keep surrounding layout and data fetching as Server Components
  • Replace client-side routing with <Link> and server-side navigations

Common Migration Pitfalls

Issue Symptom Fix
Missing layout persistence Layout re-mounts on navigation Ensure layout is in layout.tsx, not page.tsx
Client Component creep High JS bundle Audit 'use client' directives, push boundary down
Data re-fetching on nav Layout content flashes Use React.cache and verify layout data fetching
Search params not reactive Filters do not update on navigation Use useSearchParams with router.push
Middleware not matching Routes unprotected Check matcher config in middleware
Images not optimized Poor LCP Use Next.js <Image> with proper sizes attribute

Performance Comparison: Pages vs. App Router

We benchmarked a real product listing page across both routers on identical infrastructure:

Metric Pages Router App Router Delta
HTML size (initial) 24 KB 18 KB -25%
JavaScript (initial) 156 KB 34 KB -78%
Total HTTP Requests 47 23 -51%
Time to First Byte 340 ms 280 ms -18%
First Contentful Paint 1.2 s 0.8 s -33%
Largest Contentful Paint 2.8 s 1.3 s -54%
Cumulative Layout Shift 0.12 0.02 -83%
Interaction to Next Paint 95 ms 18 ms -81%
Speed Index 2.4 s 1.1 s -54%

The most significant improvement is JavaScript reduction. Because Server Components render on the server, the client never downloads or parses the component code for product cards, review lists, category navigation, or any other non-interactive element. The only JavaScript that ships is for interactive widgets: the add-to-cart button, the image gallery carousel, and the filter dropdown.

The Verdict

App Router is worth adopting for new projects. The performance gains from Server Components are real and measurable. The nested layout model maps to actual application architecture better than any prior React routing solution. Streaming gives you fine-grained control over the loading experience.

The trade-offs are real but manageable:

  • Search params are more verbose. The server-side pattern is correct for SEO and performance, but filtering-heavy UIs require more client-side plumbing than Pages Router.
  • The mental model shift is significant. Server vs. client component boundaries require discipline. Teams need to unlearn patterns from Pages Router and React tradition.
  • Ecosystem compatibility varies. Some npm packages assume a client-rendered DOM and break in Server Components. Audit your dependencies before committing.

For existing Pages Router applications, the threshold for migration depends on your performance requirements. If your pages load under 2 seconds and your Core Web Vitals are green, there is no urgent need to migrate. But if you are chasing the next tier of performance — sub-second LCP, near-zero CLS, minimal JS bundles — App Router is the path.

The framework has matured significantly since its beta release. The remaining rough edges are being smoothed with every release. For teams building new production applications in 2026, App Router is the clear default choice.