Contents
A New Mental Model, Not a New Folder
The App Router has been stable since Next.js 13.4 and matured considerably in Next.js 14. We run a Next.js front end in production, and having worked with both routers, my main advice is this: treat it as a new architecture, not a refactor. The file conventions are the easy part. The hard part is that data fetching, rendering and caching all work differently, and the defaults are designed for a different kind of site than many e-commerce teams are used to.
This post is about what I'd tell a team starting that journey today, on Next.js 14.
What Actually Changes
| Concern | Pages Router | App Router |
|---|---|---|
| Components | Client components rendered on the server, then hydrated | React Server Components by default; opt in with 'use client' |
| Data fetching | getServerSideProps, getStaticProps |
async components calling fetch or your data layer directly |
| Caching | Explicit per page (SSR, SSG, ISR) | Layered caches with defaults; per-fetch and per-route config |
| Revalidation | revalidate in getStaticProps, on-demand res.revalidate |
revalidate option, revalidatePath, revalidateTag |
| API endpoints | pages/api/* |
Route handlers in route.ts |
| Mutations | API route plus client fetch | Server actions |
| Layouts | _app and per-page patterns |
Nested layout.tsx, loading.tsx, error.tsx |
| Navigation hooks | next/router |
next/navigation |
React Server Components
Server Components are the core of the App Router. They run only on the server, can be async, can talk directly to your data layer, and send no JavaScript to the browser. For content-heavy pages such as product listings and landing pages, that is a genuine win for bundle size and performance.
The discipline you need is around the client boundary. Anything with state, effects, event handlers or browser APIs must be a Client Component, marked with 'use client'. That directive marks a boundary: everything imported into that file becomes client code too. The pattern that works is to keep pages and layouts as Server Components and push 'use client' down to the smallest interactive leaves: the add-to-basket button, not the whole product page.
Props passed from a Server Component to a Client Component must be serialisable. You can't pass functions (other than server actions), class instances or database models. That constraint tends to improve your data contracts rather than hurt them.
Caching: The Part That Surprises Everyone
In Next.js 14, there are four layers of caching: request memoisation, the data cache, the full route cache and the client-side router cache. The defaults lean hard towards caching:
fetchrequests are cached by default in the data cache, across requests and deployments, unless you opt out.- Routes are statically rendered by default unless they use dynamic functions such as
cookies(),headers()orsearchParams, or opt out explicitly. - GET route handlers are cached by default when they don't use dynamic functions or request data.
- The client router cache keeps visited routes in the browser for a period, so a user can see stale data after a mutation unless you revalidate.
For a marketing site that is ideal. For a subscription account area showing a customer's next delivery, it is a bug waiting to happen. My rule is to be explicit on every data access: state whether it's cached, for how long and which tag invalidates it.
// app/products/page.tsx
export const revalidate = 300;
async function getProducts() {
const res = await fetch(`${process.env.CATALOGUE_API_URL}/products`, {
next: { revalidate: 300, tags: ['products'] },
});
if (!res.ok) throw new Error('Failed to load products');
return res.json() as Promise<{ id: string; name: string }[]>;
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}For personalised data, opt out with cache: 'no-store' on the fetch or export const dynamic = 'force-dynamic' on the route. If you're not using fetch (for example, calling an SDK or database client), wrap the function in unstable_cache if you want it in the data cache. Despite the name, it is widely used.
It's worth noting that the Next.js 15 release candidate, announced last month, proposes flipping several of these defaults so that fetch requests and GET route handlers are no longer cached unless you opt in. That's a strong signal: be explicit now, and the upgrade will be much less painful.
Revalidation and Server Actions
Server actions let a form or button call a server function directly, without writing an API route. They became stable in Next.js 14 and pair naturally with tag-based revalidation:
// app/account/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { getSession } from '@/lib/auth';
import { skipDelivery } from '@/lib/deliveries';
export async function skipNextDelivery(deliveryId: string) {
const session = await getSession();
if (!session) throw new Error('Unauthorised');
await skipDelivery(session.customerId, deliveryId);
revalidateTag(`deliveries:${session.customerId}`);
}The important point, easy to miss in tutorials: a server action is a public HTTP endpoint. Anyone can call it with any arguments. Authenticate and authorise inside every action, validate inputs with a schema library such as Zod, and never trust an ID just because your UI only shows the user their own records.
Route Handlers
Route handlers (route.ts) replace pages/api. They use the standard Web Request and Response APIs, which is cleaner. Two cautions: remember GET handlers may be cached by default in Next.js 14, and resist moving your whole backend into them. My preference is to keep route handlers for things that belong to the front end, such as webhooks for revalidation and backend-for-frontend aggregation, and leave business logic in dedicated backend services.
Migrating Route by Route
The best thing about the App Router is that you don't need a big bang. app/ and pages/ can coexist in the same project, so you can migrate one route at a time. The approach I recommend:
- Move shared layout first. Recreate
_appand_documentas a rootlayout.tsx, keeping providers in a small Client Component. - Start with low-risk, read-heavy pages, such as content and product listing pages, where Server Components give the biggest win.
- Leave checkout and account pages until last, once the team understands caching behaviour.
- Migrate API routes only when there's a reason.
pages/apicontinues to work. - Delete the Pages Router version as soon as each route moves. A route can't exist in both, and lingering duplicates confuse everyone.
Expect navigation between an app/ page and a pages/ page to be a full page load rather than a client-side transition. That's usually acceptable during migration, but plan your order so frequently linked pages move together.
Common Gotchas
- Context providers must be Client Components. Wrap them in a single
providers.tsxand render it from the root layout. - CSS-in-JS libraries that rely on runtime style injection need extra setup or don't support Server Components. CSS Modules and Tailwind work cleanly.
- Middleware runs on the Edge runtime, so Node-specific libraries won't work there. Keep middleware thin.
- Leaking server code to the client. Use the
server-onlypackage in modules that must never be bundled for the browser, such as those reading secrets. error.tsxmust be a Client Component, andloading.tsxchanges perceived performance more than you'd expect. Design both deliberately.- Stale data after mutations is almost always a missing
revalidateTagorrevalidatePath, or the client router cache doing its job.
Advice by Stage
- New project: start on the App Router. Be explicit about caching from day one.
- Established Pages Router app: migrate incrementally, starting with content pages. Don't migrate for its own sake; pick routes where Server Components or layouts give real benefit.
- Large platform with many teams: agree caching and data-access conventions centrally, and write them down, before teams migrate independently.
The Takeaway
The App Router is a better architecture for most e-commerce front ends, but it rewards teams who understand Server Components and the caching model before they write code. Migrate route by route, push 'use client' to the leaves, treat server actions as public endpoints, and be explicit about every cache. That habit will also make the move to Next.js 15's changed defaults straightforward when it lands.
