TL;DR. Next.js ships as a framework for pages that render and forget. Production systems need auth checks that survive a middleware bypass, background jobs that outlive a request, and caching you chose on purpose. None of that is automatic, and the gap between a marketing site and an internal system shows up exactly there.
The bug that proved middleware is not a security boundary
In early 2025, a critical vulnerability landed in Next.js: CVE-2025-29927, CVSS score 9.1. The flaw let an attacker spoof an internal header, x-middleware-subrequest, and skip middleware entirely. If your only authorization check lived in middleware, an attacker could walk straight past it into pages and routes that were supposed to be locked down.
The versions affected were broad: anything before 12.3.5, 13.x before 13.5.9, 14.x before 14.2.25, and 15.x before 15.2.3. Vercel and Netlify-hosted apps were not exposed, since their platform layer sits in front of the vulnerable code path. Self-hosted apps running next start with output: standalone were the ones actually at risk.
That detail matters more than the CVSS number. It means the bug hit hardest exactly where teams had gone furthest from a default marketing-site deployment, running their own infrastructure because the app had grown into something that needed it. The lesson that stuck in the community afterward is blunt: middleware is not a security boundary. It is a place to redirect a logged-out user to a login page, nothing more. Every place that actually reads or mutates protected data has to check permissions on its own.
We build internal systems on this exact stack, and this is the first assumption we throw out when a marketing site turns into a product.
RBAC in the App Router is four checkpoints, not one
A marketing site usually has one authorization question: is this visitor logged in? A production system with roles has a harder question at every layer: is this specific user allowed to do this specific thing, right here?
In the App Router, that check has to happen independently in up to four places:
- Middleware. Fine for redirecting anonymous visitors, not for anything more specific.
- Server Components. The page itself has to re-check permissions before rendering data, even if a parent layout already gated the route.
- Server Actions. Every mutation needs its own check. A permission added to the page that calls the action does nothing if the action itself trusts its input.
- Route Handlers. Any API-style endpoint needs the same discipline as a Server Action.
Client components can hide a button or a menu item for a role that should not see it, but that is UI polish, not access control. A user who opens dev tools or calls the endpoint directly does not care what the button looked like.
The pattern that holds up in practice is a small authorize() helper, called at the top of every Server Action and Route Handler, that checks the session against the specific resource and action being performed. It is not exotic. The risk is entirely social: nothing in the framework forces a developer to remember it. The bug that gets shipped is almost always the same shape, a permission check added to the page and quietly missing from the action underneath it.
Reports, exports and emails do not belong inside a request
Marketing sites render a page and respond. Production systems generate PDFs, export spreadsheets, send bulk email and crunch analytics, work that can run for minutes, not milliseconds. Try to do that inside a Server Action or a Route Handler and you will eventually hit a wall.
Serverless functions have a ceiling. Vercel is commonly cited as running a default serverless timeout of 300 seconds, extendable to roughly 800 seconds on Enterprise plans, though that figure comes from third-party sources rather than an official published guarantee we could independently confirm. Edge Runtime functions are built for a different job entirely, sub-five-second execution, with a trimmed API surface that drops full Node.js support, no fs, no path, limited crypto. AWS Lambda, for comparison, tops out at 15 minutes. None of these numbers are generous enough for a report that touches a real dataset, and a job that runs fine in development against a handful of test rows will time out in production against the real one.
The fix is architectural, not a bigger timeout. Treat Next.js as the frontend and rendering layer, and push long work somewhere else: a queue like SQS or RabbitMQ, a worker built on something like BullMQ and Redis, or a dedicated backend service that Next.js talks to over an internal API. The request that kicks off a report should return immediately with a job ID. The UI then polls or subscribes for status, and shows a result when it is ready. Vercel's waitUntil API gets mentioned a lot here, and it is worth calling out explicitly: practitioners flag it as insufficient for genuinely long-running work, because it still needs real compute behind it, not a background timer bolted onto a serverless invocation.
This is the same decision we made building a visa case-management system: deadline checks and notifications run as their own scheduled process, not inside a page load, because a request handler is the wrong place to keep state that has to survive past the response.
Server Actions are for mutations, not for fetching data
It is easy to reach for a Server Action every time you need data on the server, since it is convenient and colocated with the component. Current Next.js guidance is consistent on this point: Server Actions are meant for mutations, first-party writes triggered from your own UI. Reads belong in Server Components or Route Handlers.
Recent versions of Next.js do add real protection to Server Actions. They enforce POST, they compare the Origin header against the Host to block cross-site calls, and Next.js 16 encrypts action IDs so internal references are not exposed for replay. That is good default hygiene, and it is not authorization. A request that passes the Origin check can still come from a logged-in user who should not be allowed to do what they are asking to do.
The practical split for a production system: Server Actions for the forms and mutations your own interface triggers, Route Handlers for anything a webhook, a mobile client, or a third party needs to call, and Server Components for the reads that render a page. Mixing these up is not fatal on day one, but it accumulates into the kind of code where nobody is sure anymore which layer is supposed to be checking what.
The caching default that quietly changes your dashboard
Next.js 15 flipped a default that a lot of teams did not notice until it hit them. In Next.js 14, fetch requests, GET Route Handlers, and the client-side Router Cache were cached by default. In Next.js 15, all three switched to uncached by default. You now opt in explicitly, with { cache: 'force-cache' }, a revalidate value, or export const dynamic = 'force-static'.
The change was a direct answer to a very common complaint under the old default: "why isn't my data updating?" Fair enough for a marketing page that rarely changes. Less fine for teams that had a dashboard or a reporting page quietly benefiting from caching they never asked for, and lost that performance the moment they upgraded.
A related trap shows up specifically on internal dashboards: calling cookies() or headers() in a root layout. That single call forces the entire route tree under it into dynamic, uncached rendering, even if ninety percent of the page is static reference data that has not changed all week. The fix is to push that call down to the smallest component that actually needs the request context, and let everything else render statically or with an explicit revalidate window.
If you are migrating a reporting product from Next.js 14 to 15, treat every fetch call as a decision, not an inheritance. Audit them one by one, decide what should be fresh on every load and what should be cached for a minute or an hour, and write that down in the code instead of trusting whatever the framework happened to default to that year.
A short audit for your own app
If you inherited a Next.js app that grew from a landing page into a product, five minutes with this checklist tells you where the gaps are:
- Does authorization live only in middleware, or does every Server Action and Route Handler check permissions on its own?
- Are Server Actions doing mutations only, or are some of them quietly fetching data too?
- Does anything that takes more than a few seconds, a report, an export, a bulk send, run inside a request handler?
- After upgrading to Next.js 15, did you explicitly set caching on your fetch calls, or are you trusting whatever came out of the box?
- Is there a
cookies()orheaders()call sitting in a layout that forces more of the app into dynamic rendering than it needs to?
None of these questions have a framework-level fix. They are modeling decisions, the same kind we walk through with every internal system we build. If your Next.js app has quietly turned into the backbone of your business and you are not sure it can take the weight, that is exactly the conversation to have with us before the next incident forces it.



