🚀 Executive Summary
TL;DR: Vercel middleware can unexpectedly double costs by executing on every asset request, not just page loads. The primary solution involves configuring a `matcher` to explicitly exclude static files, or architecturally moving logic to specific pages or externalizing asset hosting to reduce unnecessary invocations.
🎯 Key Takeaways
- Vercel middleware, by default, executes on *all* requests, including static assets like CSS, JS, and images, leading to unexpected cost spikes.
- The `matcher` configuration in `middleware.ts` is a quick fix to significantly reduce invocations by explicitly excluding static asset paths using a path-to-regexp string.
- For robust, long-term solutions, consider moving non-global middleware logic into `getServerSideProps`, Higher-Order Components (HOCs), or dedicated API routes, or host static assets on a separate CDN service.
Vercel middleware unexpectedly doubling your costs? Learn the root cause—middleware executing on every asset request—and discover three practical fixes, from a simple config change to a more robust architectural solution.
My Vercel Bill Doubled Overnight. Here’s Why Middleware Was The Culprit (And How We Fixed It).
I remember getting a PagerDuty alert at 2 AM. My heart sank, expecting to see `prod-db-01` on fire or our main API gateway spitting out 503s. But it wasn’t a server outage. It was a billing alert from our cloud provider. Our Vercel costs had spiked, seemingly out of nowhere, doubling our projected spend for the month. After a frantic hour of digging through logs, we found the culprit: a single, innocent-looking `middleware.ts` file we had pushed a week earlier. It’s a gut punch every engineer feels eventually—a simple change with a massive, unintended financial consequence. If this sounds familiar, you’re in the right place.
First, Let’s Understand The “Why”
When you read the Vercel docs, it’s easy to get excited about Middleware. It’s powerful! You can handle authentication, A/B testing, and redirects at the edge, before the request even hits your serverless functions. What’s less obvious, and what burned us, is the scope. By default, Vercel middleware runs on every single request that comes into your project. That doesn’t just mean page loads. It means requests for:
- Your CSS files (`/styles.css`)
- Your JavaScript bundles (`/_next/static/chunks/main.js`)
- Every image (`/images/logo.png`)
- The favicon (`/favicon.ico`)
Each of these tiny requests invokes a middleware function. If you have thousands of users, each loading dozens of assets, you’re looking at hundreds of thousands of extra invocations you’re paying for. The platform is doing exactly what you told it to, but not what you meant for it to do.
Three Ways to Fix It: From Quick Patch to Proper Architecture
Look, there’s no single magic bullet. The right solution depends on your team’s timeline, the complexity of your middleware, and how much you want to refactor. Here are the three paths we considered.
Solution 1: The Quick Fix (The Matcher Config)
This is the fastest way to stop the bleeding. Vercel provides a `config` object in your middleware file where you can specify a `matcher`. This uses a path-to-regexp style string to tell Vercel which routes the middleware should actually run on. It’s an explicit “opt-in” rather than the default “opt-out”.
The goal here is to exclude all static assets and anything under the `_next` directory. Here’s the code that immediately cut our invocations by over 90%:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// This function can be marked 'async' if using 'await' inside
export function middleware(request: NextRequest) {
// Your auth logic, redirect, etc.
console.log('Middleware running for:', request.nextUrl.pathname);
return NextResponse.next();
}
// THIS IS THE IMPORTANT PART
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
Is it hacky? A little. That regex can get complicated. But did it work and let my team get some sleep? Absolutely.
Solution 2: The Architectural Fix (Move The Logic)
After the immediate fire was out, we had a retro. We asked ourselves, “Does this logic truly need to run on every page at the edge?” Our middleware was checking for a user’s auth token and redirecting if it was missing. For us, this was only critical for dashboard pages, like `/account` or `/settings`.
The more robust, long-term solution was to remove the global middleware entirely and move that logic into the specific pages that needed it. You can do this in a few ways:
- getServerSideProps: For pages that require server-side rendering, you can perform the check directly within this function and return a redirect if necessary.
- A Higher-Order Component (HOC): Create a `withAuth` HOC that wraps your page components and handles the auth check.
- API Routes: For client-side rendered pages, have them call a dedicated API route (`/api/auth/check`) to validate the session.
This approach requires more work, but it aligns the logic with the resources that actually need it. It’s cleaner, more explicit, and avoids any future “gotchas” if someone forgets the matcher config.
Solution 3: The ‘Nuclear’ Option (Rethink Your Asset Hosting)
This is the most extreme option, but for asset-heavy sites, it’s worth considering. If the bulk of your requests are for static media (images, videos, large JS libraries), then maybe your Next.js project shouldn’t be serving them at all.
In this model, you’d host all your static assets on a dedicated service like an AWS S3 bucket, Google Cloud Storage, or Cloudflare R2, and serve them via a CDN. Your Vercel project would only handle the dynamic parts—the actual page rendering and API calls. This completely bypasses the middleware-on-every-request problem for your assets because those requests aren’t even hitting Vercel. It adds complexity to your deployment pipeline but can drastically reduce costs and often improve asset-loading performance.
Pro Tip: Don’t fall into the trap of using middleware as a golden hammer. Its power is its biggest weakness. Before adding a new `middleware.ts` file, ask yourself: “Could this be an API route? Could this be a check in `getServerSideProps`? Does it really need to run before everything?”
Which Path Should You Choose?
To make it simple, here’s how I’d break it down.
| Solution | Effort | Best For |
|---|---|---|
| 1. Matcher Config | Low | Emergency cost-cutting; when your middleware logic is genuinely global but just needs to ignore assets. |
| 2. Architectural Fix | Medium | The “correct” long-term solution for logic that only applies to a subset of pages. Improves code clarity. |
| 3. Rethink Asset Hosting | High | Large, media-heavy sites where asset requests massively outnumber page requests. |
In the end, we implemented Solution 1 immediately to stop the cost overrun. Then, over the next sprint, we implemented Solution 2, refactoring our authentication checks into the specific page components that required them. It was a painful lesson, but a valuable one. The edge is powerful, but with great power comes a potentially terrifying cloud bill.
🤖 Frequently Asked Questions
âť“ Why did my Vercel bill double after adding middleware?
Your Vercel middleware likely ran on every single request, including static assets (CSS, JS, images, favicon.ico), drastically increasing invocation counts and associated costs.
âť“ How does using the `matcher` config compare to moving logic into `getServerSideProps` for cost optimization?
The `matcher` config is a low-effort, immediate fix for global middleware that needs to ignore assets. Moving logic to `getServerSideProps` is a medium-effort architectural solution that provides cleaner code and better aligns logic with specific pages, avoiding future ‘gotchas’ but requiring more refactoring.
âť“ What’s a common implementation pitfall with Vercel middleware, and how can it be avoided?
A common pitfall is assuming middleware only runs on dynamic page requests, leading to it executing on every static asset request. This can be avoided by using the `config.matcher` to explicitly exclude static assets and `_next` paths, or by moving logic to page-specific functions like `getServerSideProps`.
Leave a Reply