Skip to content
Go To Agency
/Web Development
Web Development

Next.js 15 App Router for e-commerce: production lessons from 2026

Twelve months of running Next.js 15 App Router in e-commerce production: what works, what breaks, and the migration patterns that survived contact with reality.

By Robin MonteiroMay 27, 202610 min · 2 255 mots
Next.js 15E-commerceApp RouterISRServer Actions
Share article
Next.js 15 App Router for e-commerce: production lessons from 2026

We have shipped Next.js 15 App Router into production for seven e-commerce stores between Q2 2025 and Q1 2026. Catalogs ranging from 800 SKUs to 30,000. Traffic spikes from 20k visits/day to 180k on Black Friday. This article is the unfiltered breakdown of what survived, what we tore out, and the patterns we now consider non-negotiable.

No theory. No vendor talking points. Just numbers and code from real stacks.

App Router vs Pages Router: the actual migration cost

The Next.js team will tell you that App Router migration is incremental. That is technically true. In practice, on a mature e-commerce codebase with a cart context, an authenticated session, and a checkout flow, you should budget 3 to 6 weeks of focused engineering for a clean migration.

Here is the breakdown we measured on a 30,000 SKU Shopify Hydrogen-style storefront:

  • Week 1: Coexistence setup. app/ and pages/ running side by side, shared layout primitives extracted.
  • Week 2-3: Migrating product detail pages and category listings. These are the wins — Server Components shine here.
  • Week 4-5: Cart context. This is the trap. The cart is inherently client-state, but it needs to hydrate from server data. We rewrote it three times before settling on a hybrid pattern.
  • Week 6: Checkout. We chose not to migrate checkout to Server Actions (see below). Stayed on API routes.

The most painful surprise: getServerSideProps and getStaticProps patterns do not translate one-to-one. Mental rewiring is required. Devs who tried to "port" Pages logic directly to App Router produced code that worked but doubled the bundle size. The correct approach is to redesign data flow, not translate it.

The cart context pattern that survived

After three iterations, this is what we run in production:

// app/cart/cart-provider.tsx
'use client';

import { createContext, useContext, useOptimistic, useTransition } from 'react';
import { addToCartAction, removeFromCartAction } from './actions';

type CartItem = { id: string; sku: string; qty: number; price: number };
type CartState = { items: CartItem[]; total: number };

const CartContext = createContext<ReturnType<typeof useCartLogic> | null>(null);

function useCartLogic(initial: CartState) {
  const [isPending, startTransition] = useTransition();
  const [optimistic, applyOptimistic] = useOptimistic(
    initial,
    (state, action: { type: 'add' | 'remove'; item: CartItem }) => {
      if (action.type === 'add') {
        return { ...state, items: [...state.items, action.item] };
      }
      return { ...state, items: state.items.filter(i => i.id !== action.item.id) };
    }
  );

  function add(item: CartItem) {
    startTransition(async () => {
      applyOptimistic({ type: 'add', item });
      await addToCartAction(item);
    });
  }

  return { cart: optimistic, add, isPending };
}

export function CartProvider({ children, initial }: { children: React.ReactNode; initial: CartState }) {
  const value = useCartLogic(initial);
  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

export const useCart = () => {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart must be used inside CartProvider');
  return ctx;
};

The key insight: useOptimistic + useTransition gives you the snappy UX of a fully client-side cart while keeping the server as the source of truth. We pair this with a Server Component reading the cart cookie in the root layout to hydrate initial.

ISR for product pages: the LCP win nobody talks about

The single biggest performance win we measured came from moving product detail pages from full SSR to ISR with 60-second revalidation.

Before and after, measured on a real catalog (Lighthouse, mobile, Fast 3G throttling, 25 product pages averaged):

  • SSR LCP: 1.84s (p75), 2.91s (p95)
  • ISR LCP: 0.41s (p75), 0.78s (p95)
  • SSR TTFB: 612ms
  • ISR TTFB: 38ms (CDN-cached HTML)

The implementation is brain-dead simple, which is part of why it works:

// app/products/[slug]/page.tsx
export const revalidate = 60;
export const dynamicParams = true;

export async function generateStaticParams() {
  // Only pre-render the top 500 SKUs by revenue
  const topProducts = await getTopRevenueSkus(500);
  return topProducts.map(p => ({ slug: p.slug }));
}

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const product = await getProductBySlug(slug);
  if (!product) notFound();

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

What surprised us: pre-rendering only the top 500 by revenue (out of 30k) was enough to cover 84% of organic traffic. The long tail is cold-rendered on first hit, then cached. The 60s revalidation window is short enough that inventory and price updates feel real-time without a webhook.

For inventory-sensitive flows (out-of-stock badge, real-time stock count), we stream that fragment from a Client Component with SWR polling. The bones of the page stay static.

On-demand revalidation for catalog updates

For products that need instant updates (price changes, status toggles), we wired revalidatePath into our admin webhook:

// app/api/webhooks/product-updated/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { verifyWebhookSignature } from '@/lib/webhooks';

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get('x-signature');

  if (!verifyWebhookSignature(body, sig)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const { slug, sku } = JSON.parse(body);

  revalidatePath(`/products/${slug}`);
  revalidateTag(`product:${sku}`);
  revalidateTag('catalog');

  return Response.json({ ok: true });
}

Combined with tag-based fetching (fetch(url, { next: { tags: ['product:SKU123'] } })), this gives us sub-second propagation from admin save to live site.

Server Actions in production: stable for cart, risky for checkout

This is the section that has caused the most internal arguments on our team. Server Actions in Next.js 15 are stable. They work. But "stable" does not mean "use everywhere."

Where Server Actions earned their keep:

  • Add to cart / update quantity / remove from cart
  • Wishlist toggles
  • Product review submission
  • Newsletter signup
  • Filter and sort persistence

Where we explicitly avoid them:

  • Checkout payment intent creation
  • Order confirmation
  • Anything involving third-party payment SDKs (Stripe Elements, PayPal, Klarna)
  • Anything that needs detailed error responses with structured data

The reason is not that Server Actions are technically incapable. It is operational. When a checkout fails, you need granular telemetry, retry logic, idempotency keys, and a clear request/response trace in your logs. Server Actions hide too much of the underlying transport. With API routes, you get clean HTTP semantics, easy curl reproduction, and standard observability.

One concrete example: a Stripe webhook race condition we debugged in November 2025. The issue was traceable in 11 minutes because we had structured logs on an API route. The same flow as a Server Action would have buried the request ID under three layers of framework abstraction.

Rule of thumb we now follow: Server Actions for everything that is fire-and-forget mutation; API routes for anything where you care deeply about the response shape.

Edge runtime: where the savings are real and where they are not

We tried running our middleware, search API, and product detail page on the Vercel Edge runtime. Results were mixed.

Edge wins:

  • Middleware: A/B test cookie assignment, geo-redirects, bot detection. The 8ms cold-start versus 180ms on Node makes this a no-brainer.
  • Search autocomplete: Reads a Redis index, returns JSON. p95 latency dropped from 240ms to 47ms.
  • Image transforms via next/image: Already running on edge by default. Leave it.

Edge losses:

  • Product detail page: We tried it. The bundle size limit (4MB on Vercel) is a real constraint when you have Sanity client + cart logic + analytics. We rolled back after one week.
  • Anything using pg (node-postgres): Not edge-compatible. You need Neon's serverless driver or HTTP-based DB access. If your stack is on RDS or self-hosted Postgres, edge is off the table.
  • Cron-like maintenance jobs: Edge functions time out at 30 seconds on Hobby, 60s on Pro. Use a queue or background worker.

Our calibrated take: edge runtime is a precision tool, not a default. Run middleware and a couple of read-heavy JSON APIs on it. Keep heavy product pages on Node.

Streaming SSR and Partial Prerendering: the Q1 2026 game-changer

Partial Prerendering (PPR) shipped as stable in Next.js 15.2 (January 2026). We turned it on for our largest tenant in late January. The result was the single biggest TTFB improvement we have seen from a framework feature in five years.

Numbers from the homepage of a 30k SKU store (cached aggressively, lots of dynamic personalization):

  • Before PPR (full SSR): TTFB 740ms p75, FCP 1.2s, LCP 1.9s
  • After PPR: TTFB 290ms p75, FCP 0.5s, LCP 1.1s

That is a 60% TTFB reduction with zero changes to the data layer. The static shell is served from CDN in under 50ms. The dynamic holes (personalized recommendations, cart count, recently viewed) stream in over the same connection.

Enabling PPR:

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

const config: NextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};

export default config;

// app/page.tsx
export const experimental_ppr = true;

import { Suspense } from 'react';
import { ProductGrid } from './product-grid';
import { Recommendations } from './recommendations';
import { RecommendationsSkeleton } from './recommendations-skeleton';

export default function HomePage() {
  return (
    <>
      <Hero />            {/* static, pre-rendered */}
      <ProductGrid />     {/* static, pre-rendered */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations /> {/* dynamic, streamed */}
      </Suspense>
    </>
  );
}

The mental model is straightforward once you grok it: everything inside <Suspense> with a dynamic data source becomes a streaming hole. Everything outside is pre-rendered at build time and served from CDN.

The catch: identifying what is truly dynamic versus what is "I called cookies() but did not actually need to" takes one engineering day per major route. We found three pages where a stray headers() call was forcing the entire page into dynamic mode.

Turbopack in production: ready, with one caveat

Turbopack went stable for next dev in Next.js 15. For next build, it became opt-in stable in 15.3 (March 2026). We have been running next build --turbo in CI for our entire portfolio since April.

Build time comparisons on our largest project (30k product pre-render, MDX blog with 220 articles, 12 locales):

  • Webpack build: 8min 42s (cold), 4min 18s (with cache)
  • Turbopack build: 2min 51s (cold), 1min 09s (with cache)

That is roughly a 3x improvement on cold builds and 4x on warm. CI cost dropped meaningfully.

Bundle size comparisons (gzipped, route-level JS for product detail):

  • Webpack: 89kb initial, 142kb after route hydration
  • Turbopack: 86kb initial, 138kb after route hydration

Essentially the same. The build speed gain is the headline.

The caveat: a handful of webpack plugins do not have Turbopack equivalents yet. If your stack relies on a custom webpack loader (we had one for SVG sprites), you need to either find a replacement or stay on webpack for now. Check next.config.ts compatibility before flipping the switch.

The cache pitfalls that bit us

Next.js 15 changed default caching behavior. fetch() is no longer cached by default — you have to opt in with { cache: 'force-cache' } or { next: { revalidate: N } }. We missed this on migration day one and our origin server got hammered.

Three caching rules we now codify:

  1. Every server-side fetch must specify cache behavior explicitly. No defaults. Either 'force-cache' with revalidate, or 'no-store'. Anything ambiguous is a code review block.
  2. Tag every product, category, and content fetch. Tag-based invalidation is dramatically more flexible than path-based.
  3. Never call cookies() or headers() in a Server Component you want to be static. This silently flips the route into dynamic mode and wrecks your caching strategy. Use a Client Component or push the call down into a Suspense boundary.

When Next.js 15 is the wrong choice

Honest take: if your e-commerce stack is fully on Shopify Plus with the standard Liquid theme handling everything, you do not need Next.js. The cost of running and maintaining a headless storefront only pays off when you have:

  • Custom catalog logic Shopify cannot express (configurators, bundles, B2B price tiers)
  • Content-heavy storytelling alongside catalog (luxury, lifestyle, B2B education)
  • SEO requirements beyond what theme templates produce
  • Multi-brand or multi-region from a single codebase
  • Engineering team with a frontend lead who can own the stack

If you tick three or more of those boxes, Next.js 15 App Router is the strongest framework choice on the market today. If you tick zero, stay on a theme.

For a French-language deep dive on preparing your e-commerce stack for peak traffic events, see our companion piece Black Friday 2026 : préparer la stack technique e-commerce.

Production-ready checklist for 2026

Before shipping a Next.js 15 e-commerce store, we run this checklist:

  • Every fetch has explicit cache directive
  • Product pages use ISR with revalidation 30-120s
  • Top 10-25% of catalog pre-rendered at build
  • PPR enabled where homepage and category pages have personalization
  • Cart uses Server Actions + useOptimistic
  • Checkout uses API routes, not Server Actions
  • Middleware runs on edge runtime
  • Turbopack build verified in CI
  • Webhook handler for revalidatePath + revalidateTag on catalog updates
  • Lighthouse mobile score above 90 on product detail pages
  • Core Web Vitals tracking in production (CrUX or Vercel Analytics)

Closing thoughts

Next.js 15 is the first version that feels like it was designed for serious e-commerce rather than retrofitted. The combination of App Router + ISR + PPR + Turbopack delivers performance and DX that simply was not available in the Pages Router era.

But — and this is the big "but" — the framework rewards engineering discipline. Casual use produces casual results. Treat caching as an explicit contract, treat the static/dynamic split as a deliberate architecture choice, and the wins compound.

If you are evaluating a Next.js 15 migration or starting a new e-commerce build and want to discuss your specific catalog size, integrations, and team setup, we run headless e-commerce engagements and would be happy to talk through scope. Request a custom quote and we will respond within one business day with a tailored plan.

RM

About the author

Robin Monteiro

Co-fondateur de Go To Agency

Développeur full-stack et co-fondateur de Go To Agency, Robin conçoit des solutions web performantes avec Next.js, React et les dernières technologies.

Meet the team

Go To Agency — digital agency, Dijon (France)

The team behind this article can build it for you

Custom Next.js websites and e-commerce, SEO that ranks, and ad campaigns measured down to the return. Everything happens in writing, no meetings: describe what you need and we come back with a concrete read.

Your request lands directly in [email protected] — reply within 24 business hours, no commitment.

Share article

Questions fréquentes

How long does an App Router migration take for a real e-commerce site?+

Budget 3 to 6 weeks of focused engineering for a mature codebase with 10k+ SKUs, a cart context, authenticated sessions, and a checkout flow. The cart context rewrite is consistently the trickiest part — expect two or three iterations before landing on a pattern that handles optimistic updates, server hydration, and cookie persistence cleanly.

Should I use Server Actions for checkout?+

No. Server Actions are excellent for cart mutations, wishlist toggles, and form submissions, but checkout requires granular error handling, idempotency keys, structured telemetry, and clean HTTP semantics for debugging payment failures. Use API routes for anything involving Stripe, PayPal, Klarna, or order creation. Reserve Server Actions for fire-and-forget mutations.

Is Partial Prerendering (PPR) production-ready in 2026?+

Yes. PPR became stable in Next.js 15.2 (January 2026). We have run it on production e-commerce traffic since late January and measured a 60% TTFB reduction on personalized homepages. The main effort is identifying which routes legitimately need dynamic rendering versus which ones are accidentally dynamic because of a stray cookies() or headers() call.

Is Turbopack stable enough for production builds?+

For development, yes — Turbopack dev has been stable since Next.js 15.0. For production builds, it became opt-in stable in 15.3 (March 2026). We have been using it in CI since April 2026 and seen roughly 3x faster cold builds. The only blocker is custom webpack loaders without Turbopack equivalents — verify plugin compatibility before switching.

Related articles

Free quote
Next.js 15 E-commerce in Production: 2026 Lessons | Go To Agency