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() or headers() 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.

Frequently asked questions

Can Next.js Server Actions be used safely for admin or internal mutations, or do I still need a separate API layer?

Server Actions are fine for first-party mutations from your own UI, and current versions enforce POST plus an Origin check by default. That said, none of that replaces an explicit permission check inside the action body. Call your authorize() helper at the top of every action, every time, or you will eventually ship one that skips it.

Why does my Next.js dashboard feel slow and fully dynamic even though most of the data barely changes?

The usual culprit is a call to cookies() or headers() somewhere high in the tree, often in the root layout, which forces the whole route into dynamic rendering. Push that call down to the specific component that needs it, and cache the parts of the page that do not depend on the request.

Is middleware enough to protect admin routes in Next.js?

No. CVE-2025-29927 showed that middleware can be bypassed entirely by spoofing an internal header, and self-hosted apps running next start were the ones exposed. Treat middleware as routing convenience, and put the real authorization check in every Server Component, Server Action and Route Handler that touches protected data.

How do you run long background jobs like reports, exports or bulk email in a Next.js app on Vercel without timing out?

You do not run them inside the request handler. Serverless functions have a hard ceiling, commonly cited as 300 seconds by default and extendable on higher-tier plans, and Edge functions are built for sub-five-second work with a trimmed-down runtime. Push the job to a queue or a dedicated worker, and let the Next.js UI poll or subscribe for status instead of waiting.

What changed with data fetching and caching between Next.js 14 and Next.js 15, and will upgrading break my app?

Next.js 15 flipped fetch requests, GET Route Handlers and the client Router Cache from cached-by-default to uncached-by-default. If your app relied on the old default for a dashboard or a reporting page, you can lose performance you never explicitly asked for after upgrading, or the opposite happens and stale pages suddenly become fresh but slower. Either way, audit every fetch call and add cache or revalidate options on purpose instead of inheriting a default.

Should RBAC checks live in middleware, in the page, or in the Server Action?

All three, plus the Route Handler if you have one. The App Router does not give you a single authorization checkpoint, so permission logic has to be re-verified independently at each server-side layer that reads or writes protected data. Hiding a button on the client is a UI nicety, never a security control.

Is Next.js a good fit for an internal system or a SaaS backend, or only for the logged-out marketing pages?

It can absolutely carry a real product, but only if you stop treating it like a marketing site with a login wall. That means authorization at every server layer, background work pushed off the request cycle, and deliberate caching decisions instead of framework defaults. Get those three things right and Next.js scales into a full product backend without drama.