My top 3 Next.js annoyances
December 30, 2025I enjoy working with Next.js. I ship small-scale production applications with it and generally enjoy working in it.
Much has been written about its many shortcomings (and those of Vercel). I'm of the opinion that far too much energy is spent on complaining about the shortcomings of the framework, rather than learning to ship in spite of them, or simply avoid it and use something else that's fit-for-purpose. It's helpful, on the otherhand, to call out those shortcomings, and explain the alternatives and workarounds that us users are taking.
Here are 3 main limitations I've run into, and how you can work around them:
First: proxy.ts limitations
proxy.ts is Next.js's take on middleware. It's a single file where you define a function that gets called before every request, server action, etc.
Note: as of Next.js 16, this file has been renamed from middleware.ts to proxy.ts, which is why I use that terminology here.
While this provides the utility of middleware, the implementation is overly limited in my opinion.
There's no helper for chaining middleware
Let's take an ecommerce site as an example. Typical things you might do in middleware are adding cookies, manipulating URL query parameters, checking redirects, headers, etc.
Typically, the user will define each of these operations in its own function, and then compose them together.
However, Next.js provides no way of chaining middleware functions at all.
In a happy path, this isn't really problematic, since you can simply call one step after another. But what if one fails and you want the entire request to fail? What if you want a failed step to simply be skipped?
This, to me, is needed by almost everyone writing a proxy.ts, all while being easy to get wrong (at least for beginners, and without some creative flow of control), and is therefore something that should be provided by the framework.
Thankfully, I have come across this article by Jasser Mark Arioste on how to achieve this cleanly via higher-order functions.
Following his approach, my proxy.ts now looks clean, thanks to each step being broken out.
import { withBasicAuthProtection } from "@/middleware/basic-auth-protection";
import { chainMiddleware } from "@/middleware/chain";
import { withExpiredFbcCookieHandling, withFbclidHandling } from "@/middleware/meta";
import { withUtmHandling } from "@/middleware/utm";
export default chainMiddleware([
withUtmHandling,
withExpiredFbcCookieHandling,
withFbclidHandling,
withBasicAuthProtection([
(req) => req.nextUrl.pathname.startsWith('/api/export')
])
])Side note: I took the liberty to be pedantic and removed the index parameter from the chainMiddlewares function, since I will never need to set it to anything other than 0, and it's not needed to recurse through the chain:
function chainMiddleware([current, ...rest]: MiddlewareFactory[] = []): ChainableMiddleware {
if (current) return current(chainMiddleware(rest))
return async (request) => NextResponse.next({ request })
}No way to set proxy.ts in nested routes
Many of the Next.js constructs (like layout.tsx and error.tsx) can be defined not only at the root of the /app directory, but also inside its nested routes (e.g. /app/products, /app/collections).
It would make sense that you would want different logic to run on different sections of your web app, right?
Next.js's own explanation for this in the docs is:
While only one proxy.ts file is supported per project, you can still organize your proxy logic into modules. Break out proxy functionalities into separate .ts or .js files and import them into your main proxy.ts file. This allows for cleaner management of route-specific proxy, aggregated in the proxy.ts for centralized control. By enforcing a single proxy file, it simplifies configuration, prevents potential conflicts, and optimizes performance by avoiding multiple proxy layers.
While I 100% assume good intent here (i.e. they likely thought about this but decided against it), I think this is not good enough because:
- Not applied consistently across the app router, as many other constructs can be nested despite the potential for conflicts
- If they had come up with a proper way to chain proxy functions, wouldn't this be resolved? As the Next.js build would have a way to chain the most generic proxy functions before the more specific ones in nested routes, and consolidate them in a single function that runs in a single location, avoiding any additional latency
I have to confess, I don't really have a clean workaround here, except to add an extra filtering step in my own proxy steps to discard steps that don't need to run on certain routes. That's good enough.
Second: Server Functions/Actions
Here's a hot take: do not use Next.js's server actions. I don't think they're worth it. This became obvious to me for a few reasons:
- While Next.js does provide opt-in caching mechanisms for server actions, the underlying use of the HTTP POST method means they don’t benefit from the default caching behaviour that browsers, CDNs, and HTTP tooling apply to GET requests. This means that using them for fetching data is an anti-pattern, leaving a "hole" in the framework (there's no other type-safe way to fetch data from your own backend after a page is rendered)
- Most annoyingly, server functions do not return error messages in the response if an action fails server-side for an unknown reason. This may be okay conceptually (it's a sensible security safeguard), but this rule is only applied by the framework in production! In your development server, you'll be entirely left in the dark, since they will actually show up, leaving you under the wrong impression
For anything beyond trivial mutations, I’ve found server actions introduce more ambiguity than they remove.
What should you do instead, then? Use tRPC. It's a complete, type-safe, and consistent toolkit for RPC in TypeScript web apps.
It handles errors nicely, provides useful input and output validations via schemas, and works like a charm with TanStack Query. It performs just the same too, since the mutations are exposed from your Next.js server via route handlers.
So yeah, go ahead and ditch server actions in favour of tRPC mutations. I think Next.js should too.
Third: Where did my logs go?
This one is the one that annoyed me the most.
You're working on your Next.js app, testing it locally on localhost. Your console is showing useful access logs and stdout. Your Next.js deployments in production on Vercel do the same.
But deploy your app in a Docker container, and you'll see nothing. No logs whatsoever.
This is not a bug; it's built-in. Next.js doesn't emit logs in production when self-hosting, only when on Vercel!
What's worse, Next.js does not expose a hook that reliably observes both the request and the final response, which makes proper access logging surprisingly difficult when self-hosting.
Well, they do offer one way, which is to override the entire server via a custom one. This is obviously overkill if you just want to handle logging (and also disables some of the built-in server optimizations).
A workaround has been documented by Tomás Arribas in his aptly named article, "Log all requests and responses in Next.js".
TL;DR: the way out of this one is through an unfortunate-but-necessary patch. Check out the article here for a full walkthrough.
None of these issues are deal-breakers, and I’ll continue to use Next.js. But they point to a broader pattern: the framework optimises heavily for a managed, opinionated environment, and some of those opinions leak friction when you step outside the happy path. If you’re building anything long-lived or infrastructure-conscious, these papercuts are worth understanding early.
In each case, the missing piece isn’t convenience—it’s a small, composable primitive that belongs in the framework’s core.