1. The Three-Line Middleware That Tripled a Vercel Bill
In February a camping equipment retailer sent me a screenshot of their Vercel usage graph with the message "we didn't ship anything big". Edge middleware invocations had gone from about 9 million a month to 61 million in eleven days. Function invocations had roughly doubled. The bill was heading somewhere they had not budgeted for.
They had shipped something. A junior developer had added a middleware file to set a preferred-store cookie for a new click-and-collect feature. Twelve lines. What they had not added was a matcher, so the middleware ran on every single request that hit the deployment — every image, every font, every chunk of JavaScript, every _next/static file, every request from the uptime monitor, every scraper. Requests that had previously been pure CDN cache hits costing nothing were now invoking an edge function first.
Worse, and this took longer to find: the middleware called NextResponse.next() and set a cookie unconditionally. That made the response carry a Set-Cookie header on the ISR-cached product pages, and their downstream analytics started reporting every visitor as new because the cookie was being reissued with a fresh value on every request. So the bill was the visible symptom and the attribution data was quietly wrong for a fortnight.
The fix was a matcher and a conditional. Invocations dropped back to about 7 million. Nothing about the feature changed.
I tell this story because it captures what makes Vercel and Next.js different from generic edge computing. The platform is genuinely good, and the abstractions are genuinely leaky in specific, learnable places. If you know where the seams are, you get very fast storefronts cheaply. If you do not, you get an architecture where a twelve-line file changes your cost structure and your data quality without a single error appearing anywhere.
This article is about those seams. The general question of what belongs at the edge at all — isolates, sockets, the geography of data — I have covered in the edge computing fundamentals piece, and the cold-start and crawl-budget side of serverless in the serverless SEO article. Here I am assuming you have decided to build on this platform and want to know how it actually behaves.
2. The Five Places Your Code Can Run
Before anything else, get clear on the execution contexts, because almost every confusing Next.js behaviour resolves to "this ran somewhere I did not expect".
Build time. Static generation. Runs once, on the build machine, with full Node. Output is HTML and JSON in the CDN. Zero request-time cost. This is where you want as much of a catalogue as your build budget allows.
Edge middleware. A V8 isolate at the PoP, running before the CDN cache is consulted. Sees every matching request. No filesystem, no sockets, a small CPU budget, and — this is the part that surprises people — it runs even when the response is going to be served from cache.
Edge functions. Route handlers and pages with export const runtime = 'edge'. Same isolate constraints as middleware, but they render a response rather than deciding about one.
Node functions. The default runtime for App Router routes. Full Node, real sockets, native modules, running in a specific region — by default the region you chose when creating the project, which is iad1 unless somebody changed it. Cold starts apply.
The client. Where anything marked 'use client' hydrates and runs.
The mental model that keeps me out of trouble: middleware is a router, edge functions are for responses that need no origin data, node functions are for responses that need your database, and build time is for everything you can possibly precompute. Most storefront performance problems on this platform are a route sitting in the wrong one of those five boxes.
3. Middleware Runs Before the Cache, and That Is the Whole Cost Story
This single fact explains the camping equipment retailer's bill and about half the middleware questions I get.
On a conventional CDN, cached content is served without your code executing. On Vercel, middleware sits in front of the cache lookup. A product page that is fully static, fully cached, and would otherwise be served in 12ms from a PoP still invokes your middleware first. Middleware invocations therefore scale with total request volume, not with cache miss volume, and total request volume on a storefront includes a great deal of traffic you never think about.
So the matcher is not a tidiness concern. It is the single most consequential line in the file.
// middleware.ts
export const config = {
matcher: [
// Everything EXCEPT: static output, image optimiser, API routes,
// the well-known paths, and anything with a file extension.
// The final `.*\\..*` clause is what excludes .js, .css, .woff2, .png.
'/((?!_next/static|_next/image|api|\\.well-known|favicon\\.ico|.*\\..*).*)'
]
};
That negative lookahead is unpleasant to read and I copy it between projects rather than rewriting it, because getting it wrong is expensive in a way that does not show up in testing. There is no test that fails when your middleware runs on 40 million font requests.
A more explicit alternative, which I now prefer on larger projects because it is legible six months later, is to match only the paths that genuinely need a decision:
export const config = {
matcher: [
'/',
'/product/:path*',
'/category/:path*',
'/search',
'/account/:path*'
]
};
You lose the catch-all safety net, so a new route section needs a matcher update. I consider that a feature: it makes the cost of middleware a thing somebody decides about rather than a default.
The second discipline is to bail out early. Even within matched paths, most requests do not need the expensive branch.
export function middleware(request) {
const { pathname } = request.nextUrl;
// Cheapest possible exit. A returned NextResponse.next() with no
// mutation lets the platform serve the cached response untouched.
if (request.cookies.has('locale') && request.cookies.has('ab')) {
return NextResponse.next();
}
// Only first-time visitors reach anything that costs CPU.
return assignLocaleAndVariant(request);
}
4. What Middleware Can and Cannot Do to a Response
Middleware has four useful outputs and the differences are not obvious from the names.
NextResponse.next() — continue to the route. If you pass request headers, downstream server components can read them, which is the sanctioned way to hand a computed value from middleware into rendering:
const headers = new Headers(request.headers);
headers.set('x-geo-country', request.geo?.country ?? 'GB');
headers.set('x-ab-variant', variant);
return NextResponse.next({ request: { headers } });
// In a server component:
import { headers } from 'next/headers';
const h = await headers(); // async since Next 15
const country = h.get('x-geo-country');
The catch: calling headers() in a server component opts that route out of static rendering entirely. You have made the page dynamic in order to read a value that middleware computed at the edge. On a product page that is a bad trade — you traded a 15ms cached response for a function invocation. Recognising this trap is most of what separates a fast Next.js storefront from a slow one.
NextResponse.rewrite() — serve a different path without changing the URL. The critical detail is that the cache lookup happens against the rewritten path. So rewriting /product/x to /en-gb/product/x serves the cached copy of the latter, which is exactly what you want for locale segmentation. Rewriting to a path that is dynamically rendered, however, means you have just converted a static route into a function invocation for every visitor.
NextResponse.redirect() — a real 301 or 302 issued from the PoP. Fast, and it removes a round trip to the origin compared with redirecting in a route handler.
new NextResponse(body) — respond directly. Useful for blocking bots or serving a maintenance page. Do not use it to render anything substantial; you are in an isolate with a CPU budget.
What middleware cannot do: read or modify the response body of the route it is fronting. There is no HTMLRewriter equivalent in Next.js middleware. If you need to transform HTML in flight you are looking at a Cloudflare Worker in front of Vercel, which is a real architecture and a real operational cost, and I would want a strong reason.
5. Choosing a Runtime Per Route
The runtime segment config is a per-route decision and teams tend to make it globally, which is wrong in both directions.
// app/api/geo/route.ts — no database, pure header logic. Edge is ideal.
export const runtime = 'edge';
export async function GET(request) {
return Response.json({
country: request.headers.get('x-vercel-ip-country') ?? 'GB',
city: request.headers.get('x-vercel-ip-city')
});
}
// app/api/checkout/route.ts — Stripe SDK, Prisma, a transaction.
// Node runtime, and pinned to the region the database is in.
export const runtime = 'nodejs';
export const preferredRegion = ['dub1']; // match the database, not the user
export const dynamic = 'force-dynamic';
My defaults, which I would defend:
Edge for anything that answers from headers, cookies, or an edge KV store. Geo lookups, feature flag resolution, token validation, simple redirect endpoints.
Node for anything that touches your primary database or a heavyweight SDK. Stripe, Prisma, an ERP client, image handling, PDF generation. The edge runtime will either refuse to bundle it or force you into an HTTP-driver workaround that adds latency, and the arithmetic in the fundamentals article says you lose.
Neither, if the route can be static. This is the option people forget while arguing about the other two.
preferredRegion is worth a sentence of its own because it is routinely left at the default. If your database is in Dublin and your functions default to Washington, every query crosses the Atlantic twice. I have found this on more projects than I would have guessed, including one where it accounted for 340ms on the cart endpoint and had been there for two years.
6. What Breaks When You Set the Edge Runtime
The failure modes are consistent enough to list.
Your ORM. Prisma needs a query engine binary or the Accelerate proxy. Drizzle works with HTTP drivers. TypeORM does not work at all. If your data access layer was written for Node, moving a route to edge means rewriting it.
Anything using node:crypto beyond hashing. The edge runtime exposes Web Crypto. Libraries that reach for createHmac will fail at build time, which is at least honest.
Date and internationalisation libraries with large locale bundles. Edge functions have a bundle size limit — historically 1MB compressed on Hobby, 4MB on Pro. A full moment with locales or an unfiltered ICU dataset will blow it. The error message names the size and not the culprit, so budget an hour with the bundle analyser.
Environment variables you assumed were there. Edge functions get their environment injected at build, not at runtime, so a value changed in the dashboard after a build is not picked up until you redeploy. Node functions read the runtime environment. This asymmetry has bitten me during an incident where rotating an API key appeared to do nothing.
Long work. Edge functions have a shorter maximum duration than node functions and a much smaller CPU allowance. A route that aggregates six upstream calls will time out at the edge and be fine on node.
My rule: try edge, and the moment you find yourself installing a compatibility shim or an HTTP driver purely to satisfy the runtime, stop and use node. The shim is a signal that the work has a data dependency, and data dependencies belong near the data.
7. How ISR Actually Works Here
Incremental static regeneration is the reason most ecommerce teams choose this platform, and the mechanics are worth being precise about. I have written elsewhere about why ISR matters for crawl budget and Core Web Vitals; this is the operational layer.
A route with a revalidate value is generated on first request (or at build if it is in generateStaticParams), stored in the platform's ISR cache, and served from there. After the revalidation window elapses, the next request still gets the stale copy immediately while a regeneration runs in the background. Nobody waits, except the very first visitor to an ungenerated path.
// app/product/[sku]/page.tsx
export const revalidate = 300; // seconds
export const dynamicParams = true; // generate unlisted SKUs on demand
export async function generateStaticParams() {
// Pre-render what people actually visit; let the tail generate lazily.
// 60,000 SKUs at build time is a 90-minute build and a bad idea.
const top = await getTopSellingSkus(1500);
return top.map(sku => ({ sku }));
}
Three details that are not in the obvious place in the documentation.
The ISR cache is per-region, not global. A page generated because a customer in Singapore requested it is not automatically present in Frankfurt. The first visitor in each region pays the generation. On a catalogue with a long tail this means your "static" pages have a cold path more often than you expect, and it is one reason field data can look worse than your assumptions.
Regeneration is a function invocation and you pay for it. A five-second revalidate on a page with steady traffic means twelve regenerations a minute, each running your data fetching. I have seen a team set revalidate = 1 across the catalogue "to be safe" and effectively rebuild server-side rendering with extra steps and a larger bill.
A deploy invalidates everything. Every deployment produces a new build ID and an empty ISR cache. Deploy at peak traffic on a large catalogue and every page regenerates on demand at once, which is a self-inflicted thundering herd against your commerce API. If you deploy several times a day and your catalogue is large, this matters more than the revalidate window ever will.
8. On-Demand Revalidation Is the Part That Must Not Be an Afterthought
A time window alone means serving stale prices for the duration of the window. For a five-minute window on a promotional launch, that is a customer service problem. The answer is invalidation driven by your source of truth.
// app/api/revalidate/route.ts — called by the PIM/ERP webhook.
import { revalidatePath, revalidateTag } from 'next/cache';
export const runtime = 'nodejs';
export async function POST(request) {
const signature = request.headers.get('x-webhook-signature');
const raw = await request.text();
if (!(await verifySignature(raw, signature))) {
// An unauthenticated revalidate endpoint is a denial-of-wallet vector:
// anyone can force regeneration of your whole catalogue.
return new Response('unauthorised', { status: 401 });
}
const { sku, categoryIds = [], priceChanged } = JSON.parse(raw);
revalidatePath(`/product/${sku}`);
for (const id of categoryIds) revalidateTag(`category-${id}`);
// A price change also invalidates anywhere the price is embedded.
if (priceChanged) revalidateTag('sitemap-prices');
return Response.json({ ok: true, at: Date.now() });
}
Tags are the more maintainable half of this. Attach a tag at the fetch site and you can invalidate a concept rather than enumerating routes:
const res = await fetch(`${API}/products/${sku}`, {
next: { tags: [`product-${sku}`, `brand-${brandId}`], revalidate: 300 }
});
Then a brand-wide price update is one revalidateTag call on the brand tag rather than a loop over four hundred paths.
One organisational point, because it is the failure I see most: cache invalidation on a headless build sits between the commerce platform, the front end, and whoever owns the PIM. It therefore belongs to nobody by default. On every project where somebody's name was against it, it worked. On the ones where it was assumed, stale prices reached customers within a month.
9. Next 15 Changed the Caching Defaults and It Matters
If you are reading older material, be aware that the defaults inverted. In Next.js 13 and 14, fetch in a server component was cached by default and you opted out. Since Next.js 15, fetch is uncached by default and you opt in. Route handlers likewise are dynamic by default now rather than statically cached.
The practical consequence of upgrading without noticing is a large increase in origin traffic and function duration, because calls that used to hit the data cache now go to your API every time. It does not error. It does not warn. Your commerce API bill and your p75 both move.
// Next 15+: be explicit at every fetch site. Silence is now "no cache".
const product = await fetch(url, { next: { revalidate: 300, tags: [tag] } });
const config = await fetch(cfgUrl, { cache: 'force-cache' }); // rarely changes
const cart = await fetch(cartUrl, { cache: 'no-store' }); // never cache
I now grep for bare fetch( calls in server code during review. Every one of them should carry an explicit caching intent, the same way every database query should have a considered index.
For non-fetch data access — a database client, an SDK — wrap it, because the data cache only knows about fetch:
import { unstable_cache } from 'next/cache';
export const getStockLevel = unstable_cache(
async (sku) => db.inventory.findFirst({ where: { sku } }),
['stock'], // key prefix
{ revalidate: 60, tags: ['inventory'] }
);
10. Streaming, Suspense, and Partial Prerendering
The most useful thing streaming buys an ecommerce page is that one slow query stops holding the whole document hostage. Recommendations, reviews and stock indicators are exactly the sort of thing that is slow, non-critical, and — importantly — not needed for indexing.
export default async function ProductPage({ params }) {
const { sku } = await params; // async params since Next 15
const product = await getProduct(sku); // fast, cached, must be in the shell
return (
<>
{/* Everything search cares about is in the first flush. */}
<ProductHeader product={product} />
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema(product)) }} />
{/* These stream in later and are not indexed content. */}
<Suspense fallback={<StockSkeleton />}>
<LiveStock sku={sku} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews sku={sku} />
</Suspense>
</>
);
}
The rule I hold to: product name, price, availability, description and structured data go in the shell. Everything else may stream. Google does process streamed content, but the shell is what is guaranteed and immediate, and there is no upside to putting your price in a Suspense boundary.
Partial prerendering is the interesting development here — a static shell served instantly from the CDN with dynamic holes filled from a single streamed function response. It has been experimental for a long time and the flag has moved between releases, so I would not build a client's roadmap around it yet. When it is stable it makes the "static except for one personalised strip" case, which is the overwhelmingly common storefront case, a first-class thing rather than a workaround.
11. The Interaction That Catches Everyone: Middleware and the ISR Cache
Here is the specific trap, and I have watched three teams fall into it independently.
You have a beautifully static product page. You add middleware that reads a cookie and rewrites to a variant path, or sets a header for a server component to read. Suddenly your ISR hit rate collapses, or your pages start rendering as functions, and nobody can see why because the middleware itself is trivial.
The mechanisms, in order of how often they turn out to be the cause:
A server component reads headers() or cookies(). This opts the route out of static rendering. Full stop. The route becomes dynamic and every request is a function invocation. You did not change the route file; you changed something a component in it does.
The rewrite target is dynamic. Middleware rewriting to /[locale]/product/[sku] is fine if that route is statically generated for the locales you use, and a function invocation for every request if it is not.
The rewrite target multiplies your cache entries. Rewriting on a value with high cardinality — a customer segment, a session id — produces one ISR entry per value. Four segments across 60,000 SKUs is 240,000 entries, each generated independently, each regenerating on its own schedule.
Setting a cookie on the response. A Set-Cookie on a cached page is served to everyone who gets that cached copy, which is how the camping equipment retailer ended up reissuing a fresh cookie value to every visitor.
The diagnosis is quick once you know where to look. The build output tells you what each route is, and the response headers tell you what actually happened.
# Build output legend: ○ static, ● SSG, ƒ dynamic. Anything you expected
# to be static and shows as ƒ is a route that will invoke a function.
next build | grep -E '^\s*[○●ƒλ]' | head -40
# At runtime, the header that settles the argument:
curl -sI https://shop.example.com/product/oak-side-table | grep -i x-vercel-cache
# HIT served from cache, no function ran
# STALE served from cache, regeneration running in background
# MISS generated now, visitor waited
# BYPASS route is dynamic; every request runs a function
A route reporting BYPASS that you believed was static is the single highest-value finding available on this platform, and it takes one curl to get.
Doing this properly — segmenting without wrecking cacheability — is a large enough subject that I have given it its own article on edge personalisation, including how to construct cache keys that vary on what matters and nothing else.
12. Cache-Control Headers and Who Wins
There are three caches involved and they respect different headers, which produces a lot of confusion.
| Header | Vercel CDN | Browser | Notes |
|---|---|---|---|
Cache-Control | Yes, unless overridden | Yes | Applies to both; blunt instrument |
CDN-Cache-Control | Yes, takes precedence | No | Any CDN honours it |
Vercel-CDN-Cache-Control | Yes, highest precedence | No | Stripped before the browser |
s-maxage | Yes | Ignored | Shared-cache lifetime |
stale-while-revalidate | Yes | Partially | Serve stale, refresh behind |
The pattern I use on a dynamic-but-shareable route — a category listing with a filter, say — is to cache hard at the CDN and not at all in the browser, so a customer navigating back sees fresh content while the CDN absorbs the load:
return new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'private, no-cache', // browser: always revalidate
'vercel-cdn-cache-control': 'max-age=300, stale-while-revalidate=86400'
}
});
Do not set Cache-Control manually on a page that uses ISR. You will either fight the platform's own header or accidentally make a page cacheable in browsers that should not be, and browser caches cannot be purged. That last point deserves emphasis: an over-long max-age on an HTML document is not recoverable. You cannot invalidate it, and the customer will keep seeing a stale price until it expires. I have watched a team discover this on a Black Friday price they could not retract.
13. The Cost Model, Line by Line
The bill has a specific shape and the surprises are consistent.
Edge middleware invocations. Scale with total requests, not with cache misses. This is the line that moves when someone forgets a matcher. It is also the line that a bot problem shows up in first.
Function invocations and duration. Since the shift to Fluid compute, billing leans on active CPU time rather than pure wall-clock, which is a genuine improvement for storefront work — a function awaiting a slow commerce API is mostly idle, and you used to pay for the waiting. It does not change the fact that a route which should have been static is costing you an invocation per visitor.
Image optimisation. Priced per source image transformed, not per delivered image. A catalogue refresh that changes 60,000 product photos is a bill on the day it lands. Two mitigations: set images.minimumCacheTTL high, and constrain deviceSizes so you are not generating eight widths of every image nobody requests at those sizes.
// next.config.js — fewer variants, cached longer.
module.exports = {
images: {
deviceSizes: [640, 828, 1080, 1920], // four, not the default eight
imageSizes: [96, 256],
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 60 * 60 * 24 * 31 // product photos do not change hourly
}
};
Bandwidth. Frequently the largest line on an image-heavy catalogue, and the one people never think of as an application concern.
ISR writes and reads. A very short revalidate window across a large catalogue generates a lot of both.
Build minutes. Statically generating a large catalogue on every deploy costs time proportional to catalogue size, and you deploy more often than you rebuild the catalogue. This is the argument for pre-rendering the top slice and letting the tail generate on demand.
One number worth computing before you commit: what fraction of your requests are bots. On a large indexed catalogue it is routinely a third, sometimes more once you count the scrapers. Every one of those is a middleware invocation, and a fair share are ISR misses on long-tail URLs that no human has requested in a month. Bot management is a cost control on this platform, not just a security measure.
14. Preview Deployments, Skew, and the Boring Operational Bits
Two platform behaviours worth handling deliberately.
Every branch gets a public URL. Left alone, those get discovered and indexed, producing duplicate content across dozens of hostnames and occasionally outranking production for long-tail queries. Turn on deployment protection, and belt-and-braces it in middleware:
export function middleware(request) {
const res = NextResponse.next();
if (process.env.VERCEL_ENV !== 'production') {
res.headers.set('x-robots-tag', 'noindex, nofollow');
}
return res;
}
Version skew. A customer with your site open when you deploy has old client JavaScript requesting chunks that no longer exist, and old server action ids that the new deployment does not recognise. The symptom is a burst of client errors in the minutes after every deploy that everyone learns to ignore. Skew protection pins a session to its original deployment for a configured window and makes the problem go away; it is off by default and I turn it on for every commerce project.
While we are on boring things that pay: set preferredRegion deliberately, check it after every project someone else set up, and put it in the pull request template if you can. It is a one-line change with a bigger effect than most optimisation work.
15. Worked Example: 41,000 SKUs, Four Markets
A homeware and small-furniture retailer, Shopify Plus as the commerce backend, custom Next.js storefront on Vercel, App Router, selling into the UK, Ireland, Germany and the Netherlands. They came to me with a p75 largest contentful paint of 4.1 seconds and a Vercel bill of about £2,800 a month, which was more than their Shopify subscription and felt wrong to them. It was.
What we found. Every product page was rendering as a function. The build output showed ƒ against the product route, which nobody had ever looked at. The cause was two levels deep: a <CurrencyProvider> in the layout called cookies() to read a currency preference. That one call made the layout dynamic, and a dynamic layout makes every page under it dynamic. 41,000 product pages, all server-rendered on demand, all fetching from the Shopify Storefront API on every request.
Second finding: middleware with no matcher, running on everything, for a piece of logic that applied to four paths.
Third: revalidate = 10 on category pages, set during development and never revisited, generating a regeneration every ten seconds per category across 300 categories.
What we changed. Currency moved out of the server render entirely. The page ships prices in the store's base currency in the HTML, with the numeric value and currency code in a data attribute, and a small client component swaps the display for visitors whose preference differs. The structured data keeps the base currency, which is correct and also what Google prefers to see consistently. This removed cookies() from the layout and the whole route tree went static.
The top 2,000 SKUs by revenue were added to generateStaticParams; the remaining 39,000 generate on demand with a thirty-minute window plus webhook invalidation from Shopify's product and inventory topics. Category revalidate went from 10 seconds to 15 minutes with tag-based invalidation.
Middleware got a matcher covering four path prefixes, and its only remaining job is a locale redirect for visitors with no locale in the path and no locale cookie.
Images: device sizes cut from eight to four, minimum cache TTL raised to a month.
The result, measured over the following six weeks. p75 LCP went from 4.1s to 1.3s. Time to first byte at p75 from 890ms to 96ms. The Vercel bill settled at about £610 a month — a 78% reduction, of which roughly half was function invocations, a quarter middleware, and a quarter image optimisation. Shopify Storefront API call volume dropped by 94%, which mattered because they had been intermittently hitting rate limits during traffic spikes and nobody had connected the two.
What went wrong along the way. The client-side currency swap caused a visible flash on slow connections: base-currency price rendered, then replaced about 400ms later. On a furniture site where the number is large, that flash reads as a price change and it generated actual support tickets. We fixed it by writing the preferred currency into a cookie that middleware turns into a rewrite to a per-currency variant path — four cache variants, which is a cardinality we can afford — so the correct price is in the HTML for returning visitors and only genuinely new visitors see the base currency. That is the right answer and it is the one I should have proposed first instead of reaching for the client-side fix because it was quicker to ship.
The other misstep: we set the thirty-minute revalidate window before wiring the Shopify webhooks, on the theory that we would do invalidation in the following sprint. A promotional price went live and took twenty-six minutes to appear on the long-tail pages, during which the marketing team was sending traffic to it. Do the invalidation first. The window is the backstop, not the mechanism.
16. Traps Worth Knowing Before You Hit Them
A list I have accumulated, roughly ordered by how much time each has cost somebody.
A single cookies() or headers() call in a shared layout makes every page dynamic. The worked example above. Check the build output after every release; it is the cheapest regression test on this platform.
searchParams makes a page dynamic. Reading it in a server component means the route cannot be static. On a category page with filters this is often unavoidable, but handle the unfiltered case as a static route and treat filtered views separately.
generateStaticParams returning too much. Build time is roughly linear in the number of paths, and a 40-minute build changes how often a team deploys, which changes everything else.
dynamic = 'force-dynamic' copied from a tutorial. It appears in a lot of example code as a way to make something work during development, and then it ships. Grep for it.
Server actions on high-traffic paths. A server action is a POST to your deployment and it is never cached. Fine for a form; not a way to fetch data on render.
Middleware size limits. Middleware bundles have a tight ceiling. Importing a large i18n library or a full analytics SDK into middleware fails at build with a message about size, and the fix is always to move the work rather than to trim the import.
Unauthenticated revalidation endpoints. Covered above, and worth repeating because I have found two live ones during audits.
Soft 404s on generated pages. A missing SKU that renders a "not found" component with a 200 status is thin content in the index. Call notFound() so the framework sets the status, and verify with curl rather than with your eyes.
import { notFound } from 'next/navigation';
const product = await getProduct(sku);
if (!product) notFound(); // real 404 status, not a 200 with sad text
17. When I Would Not Use This Stack
Because the honest version includes the cases against.
A team without a JavaScript specialist. The caching model has real depth and the failure modes are silent. A Shopify theme, or Magento with Hyvä, will get a small team further with less risk than a headless build they cannot debug.
Very high steady traffic with predictable load. Per-invocation pricing is excellent for spiky traffic and mediocre for a flat, high baseline. Model it against your actual traffic shape rather than the marketing curve.
Heavy back-office functionality in the same application. Bulk exports, report generation and long-running imports fit function timeouts badly. Put them on something without a timeout and let the storefront be a storefront.
Regulatory data residency requirements. You can pin functions to a region, but the ISR cache, the edge network and the logging pipeline are all part of the picture, and satisfying an auditor about all of it is more work than pinning one config value.
18. Questions That Come Up
"Should middleware be edge or node?" Edge, in practice. Node middleware exists behind a flag in recent versions and is useful if you genuinely need a Node-only library in the request path, but it reintroduces cold starts on a code path that runs on every request. If you need Node in middleware, my first question is whether that work belongs in a route handler instead.
"Is ISR safe for prices?" Only with on-demand invalidation wired to your source of truth. A revalidation window on its own means serving a stale price for the length of the window, which is a customer-service problem and in some jurisdictions worse than that. Invalidate on change; use the window as a backstop.
"Why is my page dynamic when I did not make it dynamic?" Something in its tree called cookies(), headers(), searchParams, draftMode(), or an uncached fetch under Next 15 defaults. The build output identifies which routes; finding the call is a matter of bisecting the component tree, and it is nearly always in a layout or a provider rather than the page.
"Can I use Vercel with Shopify or Magento?" Yes, and it is the common shape — commerce backend for catalogue, cart and orders, Next.js for the storefront. The thing to plan properly is invalidation from the backend's webhooks, and the thing to watch is API rate limits, which headless storefronts hit far sooner than themes do because every uncached render is an API call.
"Do I need Vercel to run Next.js?" No. It self-hosts, and Next 15 improved the self-hosting story considerably, including a supported way to plug in your own ISR cache handler. What you take on is running that cache handler, the image optimiser, and the edge layer yourself. For a team with platform engineers that can be cheaper. For a team of four it is a false economy.
"Is the App Router worth migrating to from Pages?" If you are shipping features on Pages Router and it is fast, no urgency. If you are starting fresh, yes. Halfway migrations are the expensive state — two routers, two data-fetching models, two caching models, and every new developer confused about which rules apply.
"How do I stop deploys nuking my ISR cache?" You mostly cannot on the managed platform; a new build ID means a new cache. What you can do is reduce the blast radius: pre-render the pages that carry most of the traffic so they are warm at deploy, and avoid deploying into peak hours on a large catalogue.
19. Where I Would Start
Five things, in order, all of which take less than a day.
Run next build and read the route table. Every route marked dynamic that you believed was static is a function invocation per visitor and a slower page. This is the highest-yield twenty minutes available on this platform, and in the worked example above it was the entire finding.
Curl your top ten pages and look at x-vercel-cache. If you see BYPASS on a product page, you have the same problem the retailer had. If you see MISS repeatedly on the same URL, something is invalidating more aggressively than you think.
Open middleware.ts and check the matcher. Then look at your middleware invocation count next to your total request count. If those two numbers are close, you are running code on assets and paying for it.
Check preferredRegion against where your database and commerce API actually live. Then count the sequential awaits on your slowest route and parallelise what has no dependency between the calls.
Wire on-demand revalidation from your commerce backend's webhooks before you touch a revalidate window. Everything else here is performance; this one is correctness, and stale prices cost more than slow pages.
Then stop and measure for two weeks before doing anything cleverer. Streaming boundaries, partial prerendering, edge runtime migrations and bundle trimming are all real techniques and all worth less than moving a route out of the function path entirely. The platform's defaults are good. What costs money and speed is the small number of places where an innocuous line of application code quietly changes which of the five execution contexts your page lives in — and once you know to look for those, this is a genuinely excellent way to build a storefront.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
Leveraging Edge Computing for Real-Time Personalization
Vercel Edge functions and KV storage.
-
Headless Commerce: Architecture, SEO & Performance Strategies
Next.js ISR static regeneration.