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.

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:
getStaticPropsfor static generation.getStaticPathsfor dynamic static routes.getServerSidePropsfor per-request server rendering.revalidateinsidegetStaticPropsfor ISR.- API routes under
pages/api. _app.tsxand_document.tsxfor 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, andcookiesare async.paramsis aPromise, and you must await it. GETRoute Handlers are not cached by default.- The old
getStaticProps/getServerSideProps/getStaticPathstrio is gone, replaced by async components,fetchoptions, route segment config, andgenerateStaticParams.
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, orpages/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-statichandlers, and life withoutnext/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.
paramsis aPromise. Await it in pages, layouts, metadata, and handlers, or your slug isundefined.- Every dynamic route needs
generateStaticParamsunderoutput: "export", or the build fails outright. - Route handlers that emit feeds or sitemaps need
export const dynamic = "force-static". It works innext devand dies in the export without it. next/imageneeds a server. Setimages: { 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
More AI Coding

Building a Custom MCP Server in Python: Claude Reaches My Stack
Claude Code is sharp until it hits the edge of your machine and your private tools. I wrote three small MCP servers in Python to close that gap. Here is the real pattern, the real gotcha that bit me, and what it costs.

Claude Code Subagents in Practice: Fork Flag, Cache Leak, Worktree Trap
Fanning out subagents in Claude Code looks free until you hit the cap or your forks clobber each other's commits. These are the real fixes I learned running fanouts: the fork env flag that shares the parent's cache, the WebFetch cache leak, and the worktree pattern for parallel writers.

I Gave My AI Agents a Memory With SQLite FTS5 (No Vector DB)
Most agent-memory setups reach for Pinecone or pgvector by reflex. I put 2000+ markdown files behind SQLite FTS5 with BM25 ranking, and my agents now answer their own 'who is X' questions in under a second for zero tokens. Here is the schema, the query, and the one place lexical search loses.