💻AI Codingintermediate

App Router vs Pages Router in 2026: What I Hit Static-Exporting a Real Site

I ship a Next.js App Router site as a static export to Cloudflare Pages. Here is the real App-vs-Pages decision from someone who lives in that build, plus the four traps the official docs wave past.

By··8 min read·Reviewed
Next.js 15 App Router vs Pages Router 2026 - When to Stick With Pages

The decision, from inside a real build

This site you are reading runs on the App Router. It is not deployed to a Node server or to Vercel. It is exported to static HTML with output: "export" and served as flat files on Cloudflare Pages, zero rupees of hosting. Every gotcha below is something I hit in that exact setup, not a feature-matrix summary.

The router question people fight about online is the wrong one. "Which is modern" does not matter. The useful question is "which router reduces risk for this project, on this deploy target". A brand-new content site that you intend to ship as static files should start on the App Router. A stable product with 80 pages, a working auth layer, and a small team is often better off staying on Pages until the migration actually pays for itself.

What almost no guide tells you is that the App Router behaves very differently the moment you turn on static export. The defaults you learned for a Node deployment quietly stop applying, and the build fails in ways the happy-path docs do not cover. Those failures are what this article is about.

What Pages Router still gives you

The Pages Router maps files inside pages/ to routes and uses the older, very explicit data-fetching APIs:

  • getStaticProps for static generation.
  • getStaticPaths for dynamic static routes.
  • getServerSideProps for per-request server rendering.
  • revalidate inside getStaticProps for ISR.
  • API routes under pages/api.
  • _app.tsx and _document.tsx for the app shell and document-level customization.

A common draft error is writing ISR as getStaticRegenerate. That API does not exist. ISR is getStaticProps plus a revalidate value, nothing more.

Pages Router stays attractive when your team already knows the model, your pages are fast enough, and the project has few reasons to adopt Server Components. The data contract is boring and visible, and boring is exactly what you want when a client pays for features rather than framework churn.

What App Router changes

The App Router lives in app/. Routes are folders, public pages are page.tsx files, layouts are first-class files, and Server Components are the default. Client interactivity is opt-in through "use client". The changes that actually bite:

  • Request inputs like params, searchParams, headers, and cookies are async. params is a Promise, and you must await it.
  • GET Route Handlers are not cached by default.
  • The old getStaticProps / getServerSideProps / getStaticPaths trio is gone, replaced by async components, fetch options, route segment config, and generateStaticParams.

These are useful, but they are not free. Your team has to understand what runs at build, what ships to the browser, and where caching happens.

The static-export traps the docs gloss over

This is the part written from the build log, not the feature page. My next.config.ts is three lines that change everything:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
};

export default nextConfig;

output: "export" means there is no Node server at runtime. Every route is rendered once, at build time, into static HTML. That single switch is what breaks the App Router patterns you copied from a tutorial.

Trap 1: params is a Promise, so you await it

In the App Router, the page receives params as a Promise, not a plain object. Destructuring it directly gives you undefined for your slug, and the failure is silent until the page renders blank. The real shape, straight from this repo:

// app/tutorials/[slug]/page.tsx
type Props = { params: Promise<{ slug: string }> };

export default async function TutorialPage({ params }: Props) {
  const { slug } = await params; // await it, every time
  const tutorial = getTutorialBySlug(slug);
  if (!tutorial) notFound();
  // ...
}

Same rule in generateMetadata, layouts, and route handlers. If you trained on older Next.js examples, your muscle memory writes function Page({ params }) and reads params.slug synchronously. On this version that is a bug. Await first.

Trap 2: every dynamic route needs generateStaticParams, or the build refuses

With static export there is no server to render /tutorials/some-slug/ on demand. The build has to know the full list of slugs up front and bake one HTML file per slug. So every dynamic segment needs generateStaticParams. Miss it on one route and the build does not warn, it fails. The pattern is just a map over your content source:

// app/tutorials/[slug]/page.tsx
export function generateStaticParams() {
  return getAllTutorials().map((t) => ({ slug: t.slug }));
}

This is the inversion that catches people moving from a Node deployment. There, generateStaticParams is an optimization. Under output: "export" it is mandatory on every dynamic route, including nested ones like category and author pages. The mental model flips: you are not serving routes, you are enumerating them.

A related consequence: notFound() is a build-time signal here, not a runtime 404. There is no server to return a 404 status for an unknown slug. A path you never generated simply does not exist as a file, and Cloudflare Pages serves your 404 page for it. So notFound() inside a generated page only fires for the slugs you did enumerate, which means your data source and your generateStaticParams had better agree.

Trap 3: route handlers need force-static

This site generates its own sitemaps, RSS, and Atom feeds through Route Handlers (route.ts files). On a Node deploy a GET handler runs per request. Under static export it cannot, because there is no runtime. Next.js will not silently snapshot the response for you either. You have to tell it the handler is build-time only:

// app/rss.xml/route.ts
import { buildRssFeed } from "@/lib/rss";

export const dynamic = "force-static";

export async function GET() {
  const xml = buildRssFeed({ feedUrl: "https://autokaam.com/rss.xml" });
  return new Response(xml, {
    headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
  });
}

Every feed and sitemap handler on this site carries export const dynamic = "force-static". Drop it and the export step errors on a route that worked perfectly in next dev, because dev still runs a server and hides the problem. This is the trap that wastes an evening: it builds clean locally in dev mode, then dies in the export.

Trap 4: next/image is out, plain <img> is in

next/image wants a running image optimizer. Static export has no server to run one, which is why the config sets images: { unoptimized: true }. Even then, hot-linked external images (Unsplash and similar) plus static export are a bad pairing through the Next.js image pipeline. The honest answer is to use a plain <img> and silence the lint rule on that line:

{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={tutorial.image} alt={tutorial.imageAlt} className="w-full h-full object-cover" />

You lose automatic resizing and lazy hints, so you handle dimensions and loading yourself. For Open Graph cards I went one step further and self-host a generated /og/...png per article rather than depend on an external CDN at crawl time, because if the upstream drops the request the social card breaks and you never see it.

One more thing worth saying plainly: this is a markdown site, not MDX. Bodies are parsed with a markdown parser and frontmatter with gray-matter. There is no MDX runtime, so a stray import line or a <Widget /> tag in an article body does not render as a component, it renders as text. If you assume App Router means MDX-with-components by default, that assumption is wrong here and you will ship JSX as visible prose.

Code comparison

The same blog route, both routers.

Pages Router ISR route

// pages/blog/[slug].tsx
import type { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";

type Post = { slug: string; title: string; html: string };

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = (await fetch("https://api.example.com/posts").then((r) => r.json())) as Post[];
  return { paths: posts.map((p) => ({ params: { slug: p.slug } })), fallback: "blocking" };
};

export const getStaticProps: GetStaticProps<{ post: Post }> = async (ctx) => {
  const slug = String(ctx.params?.slug ?? "");
  const res = await fetch(`https://api.example.com/posts/${slug}`);
  if (!res.ok) return { notFound: true, revalidate: 60 };
  return { props: { post: (await res.json()) as Post }, revalidate: 60 };
};

export default function BlogPost({ post }: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}

Boring, explicit, and revalidate lives in one place. If you already have 50 routes like this, migration needs a real reason.

App Router route, static export

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

type Post = { slug: string; title: string; html: string };
type PageProps = { params: Promise<{ slug: string }> };

export async function generateStaticParams() {
  const posts = (await fetch("https://api.example.com/posts").then((r) => r.json())) as Post[];
  return posts.map((p) => ({ slug: p.slug }));
}

export default async function BlogPostPage({ params }: PageProps) {
  const { slug } = await params;
  const res = await fetch(`https://api.example.com/posts/${slug}`);
  if (!res.ok) notFound();
  const post = (await res.json()) as Post;
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}

Less prop plumbing, the page itself is async, and params is awaited. Under static export there is no revalidate doing anything useful, because nothing re-renders after the build. If you need fresh data, you rebuild and redeploy. On Cloudflare Pages that is a Git push, so I treat a content change as a deploy and move on.

Cost breakdown

Router choice alone rarely moves the hosting bill. The cost is migration time, testing time, and accidental regressions.

Cost item Pages Router App Router (static export)
Hosting Same as today Flat files, free on Cloudflare Pages
Per-request compute Whatever your server costs None, no runtime
Migration work None if staying put Routes multiplied by data and layout complexity
Training Low for classic teams Higher if new to Server Components and async params
Regression risk Lower for unchanged apps Higher during migration

For a small Indian team, put a real number on the migration. If two developers need eight working days, that is sixteen developer-days plus QA, costed at your actual rate. Do not publish a fixed rupee figure you cannot defend.

Decision guide

Stay on Pages Router when:

  • The app is in production and already meets your performance and SEO targets.
  • Most pages are dashboards, admin screens, or form-heavy flows.
  • The team depends on getServerSideProps, getStaticProps, or pages/api.
  • You do not have budget for migration tests.

Choose App Router when:

  • You are starting a new app you intend to ship as a static export.
  • You want Server Components, nested layouts, and metadata files as defaults.
  • You can enumerate every dynamic route at build time.
  • Your team is ready for async params, force-static handlers, and life without next/image.

The middle path is legal: keep an existing dashboard on pages/, build new content sections in app/, and migrate page by page. Next.js runs both directories in one project. But navigation across the two can behave differently, so test the whole user journey, not just individual pages.

Quick takeaways

  • Static export changes the App Router. The Node-deploy defaults you learned stop applying.
  • params is a Promise. Await it in pages, layouts, metadata, and handlers, or your slug is undefined.
  • Every dynamic route needs generateStaticParams under output: "export", or the build fails outright.
  • Route handlers that emit feeds or sitemaps need export const dynamic = "force-static". It works in next dev and dies in the export without it.
  • next/image needs a server. Set images: { unoptimized: true } and use plain <img> with the lint rule disabled on that line.
  • On Cloudflare Pages a content change is a redeploy, not a re-render. A Git push is your revalidate.

Related