Next.js App Router vs Pages Router — We Rebuilt the Same SaaS Feature in Both

We rebuilt a data-heavy SaaS dashboard twice — once with the Pages Router, once with the App Router. Same feature set, same database, same authentication flow, same team. We did it because our clients kept asking the App Router vs Pages Router question for their next SaaS, and the internet was full of hot takes and short on real numbers.
If you are weighing App Router vs Pages Router for a SaaS in 2026, here is the short version: pick App Router for new projects and anything public-facing where SEO and bundle size matter, and pick Pages Router for internal, fully authenticated dashboards on tight deadlines with a team new to React Server Components. You can also run both at once and migrate one route at a time. The rest of this post is the evidence behind that recommendation.
This is what we found: the actual performance difference, the code patterns that change (and the ones that don't), and the migration traps that the official docs skip.

The Feature We Rebuilt
We picked a feature that every SaaS has: an authenticated dashboard with a data table (subscribers), real-time metrics, and a role-based settings panel. Nothing exotic — but the kind of page that makes up 80% of a typical SaaS UI.
The dashboard had to:
- Render a paginated table of subscribers with search and filtering
- Display three summary metrics (total users, active this week, churn rate)
- Show a chart of signups over the last 30 days
- Protect every route behind authentication
- Support admin and viewer roles with different access levels
We built it in Next.js 16 (which uses App Router by default) and then ported the same feature to the Pages Router pattern. Same Postgres database, same ORM (Prisma), same auth library (NextAuth.js / Auth.js). The only variable was the routing and rendering model.
Pages Router Implementation: Predictable and Battle-Tested
The Pages Router approach is well-understood by anyone who has worked with Next.js since 2017. You write a page component, export getServerSideProps for data fetching, and render it all on the client.
1// pages/dashboard.tsx
2import { getServerSession } from "next-auth"
3import { authOptions } from "./api/auth/[...nextauth]"
4import { prisma } from "@/lib/prisma"
5import DashboardLayout from "@/components/DashboardLayout"
6import SubscribersTable from "@/components/SubscribersTable"
7import MetricCards from "@/components/MetricCards"
8import SignupsChart from "@/components/SignupsChart"
9
10export async function getServerSideProps(ctx) {
11 const session = await getServerSession(ctx.req, ctx.res, authOptions)
12 if (!session) {
13 return { redirect: { destination: "/login", permanent: false } }
14 }
15
16 const [subscribers, metrics, signups] = await Promise.all([
17 prisma.subscriber.findMany({
18 where: { tenantId: session.user.tenantId },
19 skip: (parseInt(ctx.query.page as string) - 1) * 20,
20 take: 20,
21 }),
22 prisma.metric.findFirst({
23 where: { tenantId: session.user.tenantId },
24 }),
25 prisma.signup.groupBy({
26 by: ["date"],
27 where: {
28 tenantId: session.user.tenantId,
29 date: { gte: thirtyDaysAgo() },
30 },
31 _count: { id: true },
32 }),
33 ])
34
35 return {
36 props: { subscribers, metrics, signups, user: session.user },
37 }
38}
39
40export default function DashboardPage({
41 subscribers, metrics, signups, user,
42}) {
43 if (user.role !== "admin") {
44 return <DashboardLayout>...viewer restricted UI...</DashboardLayout>
45 }
46
47 return (
48 <DashboardLayout>
49 <MetricCards data={metrics} />
50 <SubscribersTable data={subscribers} />
51 <SignupsChart data={signups} />
52 </DashboardLayout>
53 )
54}This works. It has shipped in thousands of production apps. The data fetching is explicit and easy to reason about — getServerSideProps runs on every request, returns props, and the page renders.
The downside: every piece of JavaScript for the entire page — MetricCards, SubscribersTable, SignupsChart, their dependencies — ships to the browser. That subscriber table component that loops over 20 rows and renders a status badge? It runs on the client. The chart library for SignupsChart? Also client-side. If you have five widgets on the dashboard, all five hydrate on the client before the user can interact with any of them.
App Router Implementation: Server Components and Streaming
The App Router version of the same dashboard looks deceptively similar until you notice what is missing.
1// app/dashboard/page.tsx
2import { getServerSession } from "next-auth"
3import { authOptions } from "@/lib/auth"
4import { prisma } from "@/lib/prisma"
5import { redirect } from "next/navigation"
6import { Suspense } from "react"
7import MetricCards from "./MetricCards"
8import SubscribersTable from "./SubscribersTable"
9import SignupsChart from "./SignupsChart"
10
11export default async function DashboardPage() {
12 const session = await getServerSession(authOptions)
13 if (!session) redirect("/login")
14
15 return (
16 <div>
17 <Suspense fallback={<MetricCardsSkeleton />}>
18 <MetricCardsWrapper tenantId={session.user.tenantId} />
19 </Suspense>
20 <Suspense fallback={<TableSkeleton />}>
21 <SubscribersTableWrapper tenantId={session.user.tenantId} />
22 </Suspense>
23 <Suspense fallback={<ChartSkeleton />}>
24 <SignupsChartWrapper tenantId={session.user.tenantId} />
25 </Suspense>
26 </div>
27 )
28}
29
30// Each data-fetching wrapper is a server component
31async function MetricCardsWrapper({ tenantId }) {
32 const metrics = await prisma.metric.findFirst({
33 where: { tenantId },
34 })
35 return <MetricCards data={metrics} />
36}
37
38async function SubscribersTableWrapper({ tenantId }) {
39 const subscribers = await prisma.subscriber.findMany({
40 where: { tenantId },
41 take: 20,
42 })
43 return <SubscribersTable data={subscribers} />
44}
45
46async function SignupsChartWrapper({ tenantId }) {
47 const signups = await prisma.signup.groupBy({
48 by: ["date"],
49 where: { tenantId, date: { gte: thirtyDaysAgo() } },
50 _count: { id: true },
51 })
52 return <SignupsChart data={signups} />
53}Three things changed:
No getServerSideProps. The page component is an async function that awaits data directly. There is no separate data-fetching function to maintain. The component is the data fetcher.
Server Components by default. MetricCards, SubscribersTable, and SignupsChart run on the server unless they have "use client" at the top. If they are purely presentational — rendering data passed as props — they never ship a single byte of JavaScript to the browser. That chart library we worried about? Only the server imports it.
Streaming with Suspense. Each data wrapper is wrapped in <Suspense> with its own fallback. The metric cards load first (fastest query), then the table, then the chart (slowest). The user sees the page shell with loading skeletons within 150ms instead of staring at a blank screen while all three queries finish sequentially in getServerSideProps.

Authentication Handling Comparison
Auth is where the two routers diverge in ways that catch people mid-migration.
1// Pages Router: auth in getServerSideProps
2export async function getServerSideProps(ctx) {
3 const session = await getServerSession(ctx.req, ctx.res, authOptions)
4 if (!session) {
5 return { redirect: { destination: "/login" } }
6 }
7 return { props: { user: session.user } }
8}
9
10// App Router: auth in the server component itself
11export default async function DashboardPage() {
12 const session = await getServerSession(authOptions)
13 if (!session) redirect("/login")
14 // render
15}
16
17// App Router: auth in layout (shared across child routes)
18export default async function DashboardLayout({ children }) {
19 const session = await getServerSession(authOptions)
20 if (!session) redirect("/login")
21 return <div>{children}</div>
22}The Pages Router pattern is predictable — every page checks auth in getServerSideProps, and you never accidentally render an unauthenticated page because the redirect happens before the component mounts.
The App Router gives you more flexibility and more footguns. You can check auth in a layout, which means all child routes inherit the check automatically. But if you fetch user data in that layout, it blocks rendering for every child route. We found ourselves reaching for nested layouts — an auth check in the root layout, then a separate data fetch in a child layout — to avoid blocking fast pages behind slow queries.
The middleware.ts → proxy.ts rename in Next.js 16 changes the story for App Router auth. You can now do full database lookups in the proxy layer because it runs on Node.js instead of Edge. But that also means your auth check runs on every request, including static assets and API calls, so you need to be deliberate about which paths it matches.
Data Fetching Patterns: What Changes and What Stays the Same
The data fetching mindset shift is the hardest part of moving to App Router.
1// Pages Router: getStaticProps for build-time data
2export async function getStaticProps() {
3 const plans = await prisma.plan.findMany()
4 return { props: { plans }, revalidate: 3600 }
5}
6
7// App Router with Cache Components (Next.js 16)
8export default async function PricingPage() {
9 const plans = await prisma.plan.findMany()
10 return <PricingCards plans={plans} />
11}
12// Caching is opt-in via "use cache" or cacheLife profilesIn Pages Router, caching was explicit: getStaticProps with revalidate for ISR, getServerSideProps for dynamic data. In App Router, everything is dynamic by default and you opt into caching with "use cache" or cacheLife profiles. This is the opposite of the old implicit caching that frustrated so many teams in Next.js 13–15.
For most SaaS pages, this is fine because most SaaS pages should be dynamic (you want the latest subscription status, not stale data). But if you have marketing pages with content from a CMS, you need to explicitly tell App Router to cache them. If you migrate from Pages Router and forget this step, your marketing page that used to be static is now hitting the database on every request.

App Router vs Pages Router Performance: Real Benchmarks
Here are the numbers from our rebuild. Same hardware, same database, same feature, same load test (500 concurrent requests over 30 seconds).
| Metric | Pages Router | App Router (well-structured) | Difference |
|---|---|---|---|
| First Load JS | 412 KB | 218 KB | 47% reduction |
| TTFB (p95) | 620ms | 340ms (streaming) | 45% faster |
| LCP | 1.8s | 1.1s | 39% faster |
| Time to Interactive | 3.2s | 1.6s | 50% faster |
| Requests/sec (server load) | 68 req/s | 42 req/s | Pages handles 62% more |
The first four rows are the story the App Router advocates tell. Less JavaScript, faster Time to Interactive, better Core Web Vitals. Server Components mean the chart library and the table render logic never reach the browser. That 47% reduction in JavaScript is real and it measurably improves user experience on mobile connections.
The last row is the one nobody mentions. The App Router version uses more server CPU because Server Components do rendering work that Pages Router offloads to the client. Under heavy concurrent load, Pages Router handles more requests per second. For most SaaS products this does not matter — your bottleneck is your database, not your Next.js server. But if you are running high-traffic server-rendered pages on a small instance, the difference shows up.
Developer Experience: Which Was Harder to Debug?
The team's honest assessment after building both versions:
Pages Router is easier to debug. getServerSideProps is a function with clear inputs and outputs — request comes in, data comes out, page renders. If something goes wrong, you can put a breakpoint in getServerSideProps and trace the issue. Every developer on the team could reason about the data flow without referencing documentation.
App Router is faster to build in once you internalise the model. The team reported being slower on day one (understanding Server Components, Suspense boundaries, "use client" placement) but faster by day five. The streaming with Suspense pattern eliminated a class of "loading state" bugs that Pages Router requires manual handling for.
The caching mental model is the biggest friction point. Pages Router's revalidate is a simple time-based cache. App Router's Cache Components + cacheLife + updateTag + revalidateTag is more powerful but requires thinking about stale-while-revalidate, tag-based invalidation, and the difference between updateTag and revalidateTag. We had two bugs in the first week where data appeared stale because we used the wrong cache API.

When to Choose App Router, When Pages Router Still Makes Sense
Pick App Router for:
- New SaaS projects. App Router is the direction of the framework and the ecosystem. Every new feature, performance optimization, and library pattern assumes App Router.
- Public-facing pages where SEO matters. The streaming SSR and smaller JavaScript bundles improve Core Web Vitals. If your landing pages, blog, and marketing site are part of the same Next.js app, App Router gives you better SEO signals.
- Mobile users. The 30-50% reduction in JavaScript is not theoretical — it is the difference between a usable mobile experience and a slow one on 4G.
- Complex layout hierarchies. Nested layouts in App Router are genuinely better than the
_app.tsx+ per-page layout gymnastics that Pages Router requires.
Pick Pages Router for:
- Pure authenticated SPAs. If every page requires login and SEO is irrelevant, Server Components give you no advantage. Your dashboard is already behind auth, so the "better Core Web Vitals" argument vanishes. A static export on S3 costs pennies and never goes down.
- Tight deadlines with a team new to RSC. Pages Router has a simpler mental model.
getServerSidePropsruns on the server, everything else runs on the client. You do not ask "should this be a Server Component or Client Component?" on every file. - Heavy concurrent server rendering. If you are rendering complex pages on a small server instance under high traffic, the server-load numbers matter. Pages Router offloads rendering to the client and handles more requests per core.
- Client-heavy libraries. Wallet libraries, rich text editors, drag-and-drop — anything that depends on browser APIs at the top level needs
"use client"wrappers in App Router. If 60% of your components need"use client", ask whether you are getting any value from the migration.
Migration Path: How to Switch Without a Rewrite
If you have an existing Pages Router project and want to migrate, do not attempt a big-bang rewrite. The Standish CHAOS data is blunt here — large projects succeed less than 10% of the time (Standish Group) — and a framework migration done as a single switchover is exactly that kind of large project.
Next.js supports running both routers in the same project. The app/ directory and pages/ directory coexist. Routes in app/ take precedence over matching routes in pages/, as documented in the official Next.js upgrade guide and the step-by-step App Router migration guide.
Our recommended approach:
- Move the simplest routes first — a marketing page with static content or a public blog page. These have minimal dependencies and low risk.
- Replace
getStaticPropswith async server components. Addexport const dynamic = "force-static"or use Cache Components to maintain static generation behavior. - Migrate
pages/apiroutes toapp/api/route.ts. The request/response API changes from Express-style (req.body,req.query) to Web API standards (Request,Response). Every Stripe webhook handler and third-party callback needs updating. - Move auth checks from
getServerSidePropsinto layouts or the proxy layer. In Next.js 16, renamemiddleware.tstoproxy.tsand take advantage of the Node.js runtime for full database access. - Replace
getServerSidePropswith async server components and add Suspense boundaries for streaming. Start with the slowest data queries first.
The full migration for a medium-sized SaaS (15-20 routes, standard auth, Stripe integration) took our team about three weeks working part-time alongside feature work. The key is incremental adoption — both routers run side by side, so there is no switchover day where everything breaks.
For more on structuring full-stack SaaS projects, see our guide on NestJS and Next.js monorepo structure and our JWT authentication implementation for auth patterns that work with either router.
Pick the Router That Matches Your SaaS
The dashboard we rebuilt twice taught us something the benchmark numbers alone cannot capture: the App Router vs Pages Router decision depends less on which is objectively better and more on the constraints you are operating under. If you are starting fresh with a team that knows React Server Components and a product that has public-facing pages, App Router wins. If you are shipping an internal dashboard in two weeks with a team that has never written "use client", Pages Router gets you to launch faster.
The good news is you do not need to decide permanently. Next.js lets you run both. Start the new routes in app/, keep the old ones in pages/, and migrate at whatever pace makes sense for your team and your deadlines.
If you are staring at a routing decision right now and cannot tell which way to jump — that is the kind of thing we argue about for a living. Pick the boring option that you can debug at 3am, because that is the one you will actually ship.
Frequently Asked Questions
App Router typically ships 30-50% less JavaScript due to React Server Components, improving INP and Time to Interactive. However, for heavy server-side rendering with high concurrent load, Pages Router can handle more requests per second because it has less server-side overhead per request. The right choice depends on your specific performance bottleneck.
No. Pages Router is not deprecated and remains fully supported. However, Vercel has made it clear that all new features — Cache Components, Turbopack improvements, streaming SSR — go to App Router first. Pages Router receives bug fixes but no new features.
Yes. Next.js supports running both routers simultaneously. Routes in the app/ directory take precedence over pages/. This allows incremental migration — you can move one route at a time without a big-bang rewrite.
Start by moving the simplest routes first — ones without complex data fetching. Replace getServerSideProps with async server components, move pages/api to app/api/route.ts, and rename middleware.ts to proxy.ts. Migrate incrementally, keeping both routers running until all routes are converted.
Yes. App Router supports static export via next.config.js output: 'export'. Use export const dynamic = 'force-static' or let Next.js automatically statically optimize routes without dynamic data. generateStaticParams replaces getStaticPaths for dynamic route generation.
Use App Router for a new SaaS. It is the direction of the framework, the React ecosystem, and every new Next.js feature. The learning curve around Server Components is real, but the long-term trajectory all points to App Router. The main exception is an internal, fully authenticated dashboard on a tight deadline with a team new to React Server Components — there, Pages Router's simpler mental model can ship faster.
For a medium SaaS (15-20 routes, standard auth, Stripe integration), budget 2-4 weeks working incrementally alongside feature work. For larger codebases (50+ routes), migrate over several months. Both routers run side by side, so there is no big-bang switchover day — Standish CHAOS data shows large projects succeed less than 10% of the time, which is the argument against a full rewrite.
Forgetting that caching is opt-in in App Router. Pages Router's getStaticProps with revalidate caches by design, but App Router caches nothing by default in Next.js 16. If you migrate a marketing page and forget to add 'use cache' or a cacheLife profile, you turn a static page into a dynamic one that hits the database on every request.
