Next.js Is Great For Standalone Web Apps, and Terrible At Everything Else
January 17, 2024
Next.js is the premier React web framework. If you want to build a cutting-edge, performant, battle-tested website with a backend to support it, Next.js is the answer. It's listed as the first option on React's own website to create a new production-ready React App, has been my go-to for all my side projects (including this site, ralexmatthews.com itself) for quite a while, and with over 5,000,000 weekly npm downloads, the community around it is enormous.
At my job, we have used statically-rendered Next.js for years for the companies main website. The need arose for another web app to be rewritten, and so the decision was obvious that we would use Next.js for this as well. It is a web app that served Server-Side-Rendered (SSR) React with little no API routes and no database needed. A match made in heaven, right?
I have been writing this web app for going on over a month, and my taste for what Next.js is has significantly soured. It feels like this final stretch of getting it connected to the rest of our ecosystem, and even standard web-app things, has been way more difficult than it needs to be, if not down-right impossible.
The Early Days
For the first few weeks of writing this brand-spanking-new Next.js app, it was the dream. A TypeScript, SSR, React App using Server Components, SCSS modules, and NodeJS to fetch data directly in the components. It was going flawlessly, and is everything I love about Next.js.
When you are going to happy path, Next.js is amazing. I was making React pages with auto-magic file-based routing in the App router. The Next.js creator tool makes everything so easy. The documentation on how to do all the easy stuff is so great, if it's even needed. It is a great framework for making a React website.
POST Request that Returns HTML
Part of the requirements for this app requires a “preview” route, where the user can POST some data to the route, and the server will render the page with the supplied data, and return it. The data includes a few strings and a couple base64-encoded images. Not the simplest payload, but nothing too crazy either. Just a few MB's worth of a POST body. But other than that, it's no different than any other SSR generated route, right? Well, as far as I can tell, this is literally impossible in Next.js 14's app
router.
The first problem one encounters when trying to POST to a page is that Next.js assumes POST requests to a page are trying to invoke a server action. Server actions are actually super cool and a great way to get out of having to write bespoke API routes any time you need something to happen on the server. Just a JS function you can pass around as props and when you call it in the React, it makes a POST request to the same route the page is at, calling the server action in its place.
The problem with this is that now Next.js assumes every POST to a page is trying to invoke a server action, and if one is not found, it 500's. I guess this kind-of makes sense if you know the process its going through to get there, but in any other web app, if I sent a POST instead of a GET to any route, you would expect a 405 - METHOD NOT ALLOWED
, not a 500 - INTERNAL SERVER ERROR
. Honestly, this seems more like a bug than anything to me.
Either way, how it handles this error is irrelevant to our problem. How do we get it to not be a server action? Well, we could try sending a PUT instead? Next.js's documentation says that only POST's can invoke server actions. Well even assuming we could even change the method the client uses (which would not work for other reasons), you cannot have access to the request body from a page, for reasons . Only headers and query params. So thats out.
You do get access to this information at the middleware level. So it is possible to intercept the POST request and data at that level, and then…? As far as I know, there is not request context or anything that you could get access to, like you would have in a Flask app for example, so you can't just forward the data. You could change it to a PUT and forward it, but see above why that doesn't work. You can change it to a GET and put all the data in the URL? The main issue there is the size of the requests. With them being sometimes multiple MB worth of image data, you would need to increase the NodeJS header size limit, and that has security implications. The middleware runs on its own runtime so even if you could set up some kind of pub-sub or queue or something in memory, I don't think the NodeJS runtime can talk directly to the web-based runtime that the middleware uses. We could save it to a file or database or something, but again, the middleware is web-based so doesn't have access to NodeJS API's, even if we did have a database.
You could set up an API route that specifically takes a POST request and data. Unfortunately Next.js prohibits returning React directly from the API route handlers, and see the middleware section about why we can't just redirect or something.
All that to say, there is no way to POST data and get HTML back, at least using the app
router. I believe this is possible in the pages
router using getServerSideData
or similar, but Next.js has all but said the pages
router is going away and to use the app
router instead. I don't want to make a brand new app with tech-debt built in.
Logging
Ok, well thats unfortunate, but I'm sure that will be the only issue we run into. After all, this is the ultimate React framework and they certainly have all the major use cases covered. Let's talk about logging.
Any production web service should have logging in one form or another. At least just the basics.
Tell me what comes in, and what I return back.
Super simple.
The way the “middleware” is set up, you have access to every request that comes in. Perfect. That covers half of our simple setup. We have full access to every request, its URL, its headers, its body, everything we need.
What we don't have, however, is any way to see what is going out. Next.js says its “middleware” has the ability to
modify the response by rewriting, redirecting, modifying the request or response headers, or responding directly.
Nowhere in there can we see what the server is actually responding with. As far as responses go, we can:
- Change what headers we respond with
- Respond directly
This would come in handy if we needed to, say, implement auth. We could get the headers, and check if the user is logged in, and redirect or return say a 403 - FORBIDDEN
directly or something. But even then, how do you want to check if a user is logged in? Unless you have a database library that can run with no NodeJS APIs, you can't do that. You can make HTTP requests? So you then need to make another HTTP request to your own server on localhost, that also gets picked up by the middleware and run through the pipeline, and eventually goes to an API route that just checks if an auth token is valid, and then once that request resolves, you can resolve this request. A bit roundabout way to handle it, by my ears.
Anyway, taking a step back, no way to get the response. You could probably handle this with some kind of reverse-proxy like NGINX, but a few things about that.
Firstly, Next.js sells itself as an all-in-one server that can handle most of the NGINX stuff anyway, like setting custom headers, redirects, rewrites, etc. It seems a shame to set up a whole other server to sit in front of this server, purely for logging reasons, especially if there is already a whole firewall/load balancing stack in front of these.
Second, the logging for this particular app is a little special, as we send the data to a separate service for processing at higher levels, so NGINX wouldn't accomplish this anyway.
Which leads to our last point.
Non-NodeJS Middleware
Even assuming we had both the request and response available at the middleware level (I know, big ask), we then need to process that data. Like I said, we have a custom logging need, as I am sure is common in enterprise companies. We have a separate service that listens on a Unix port for messages that then pipelines those messages where they need to go.
Simple enough. NodeJS has its net
package that natively supports sending and receiving data from Unix sockets. Perfect, let's do that.
Except, Next.js's middleware doesn't use NodeJS. It uses a web-based JS runtime. Web-based JS runtimes have no need to be able to read and write from files, let alone subscribe to updates from one. So it is literally impossible to post data to this Unix socket from the middleware.
Granted, a Unix socket is a little niche. But file system access is not. Tons of legitimate use cases would need to read and write from disk. You know, regular server stuff. Say you just wanted to use something like Winston, a common NodeJS logging library, to log just the requests to a big file somewhere. You can't do it from the middleware. You need to implement this logging in every place you want that logging to occur. On every page, on every API route, on every server action. It's completely untenable.
Next.js's official statement on why it doesn't use NodeJS for the middleware is mostly performance reasons, and that it is much faster to spin up a lighter-weight browser-based runtime than NodeJS. Which is a noble goal. We are all for improved performance. Especially since Vercel specifically has a vested interest in Edge runtimes for their hosting services, being able to spin up and shut down these functions quickly is of great importance. They say they specifically have chose to not let you use opt-in to using NodeJS there to prevent handing a performance foot-gun to the user, but then also say all NodeJS-activity can be put in the base layout anyway, which gets run on every route anyway.
Let's think about this. If there is already a NodeJS runtime function that gets run on every request, then what is the point of the middleware? Well, the middleware has access to the full request. But, I need both NodeJS and the full request? This also assume that the root layout also gets run on server actions and API requests, which I am currently too lazy to investigate if it does or not. But if it does, why does a React component that returns HTML get run on every HTTP request to a web server, even on API routes and server actions? And if it doesn't, then this won't work for this use case.
They then go on to say that the path forward is to just increase compatibility with the NodeJS APIs and point to libraries that have been implemented to move toward that goal. Hmmm… if only there was a JS runtime that was fully compatible with NodeJS APIs…
Error Handling
I'll keep this one short, since its in the same vein as the logging complaint, but as far as I can tell, there is not really a good way to handle errors at a global level. Next.js provides a very nice interface for showing a 500
and 404
page, just create a error.js
or not-found.js
React component that gets rendered on those specific errors. Great.
But what if we want to do something specific? What if when an API route throws an exception, we want to normalize it to something like
{
"error": {
"code": "SERVER_ERROR",
"message": "An unknown error occured"
}
}
instead of returning and HTML rendered React component to a client that is expecting JSON?
My gut would be to use the middleware, but we already know we don't have the access to know what the response is. As far as I can tell, there is no way to change what error gets sent back besides just doing a giant try/catch on every route.
So Where Does That Leave Us?
Honestly, I don't know what to do. I still love Next.js for personal projects, blogs, web apps, etc. It is so good at the stuff it does good.
The first half of the project, the UI-side stuff, has been amazing. Server/Client components are amazing. SCSS modules are amazing. Server Actions (even though they are not used in this specific app) are amazing. The work the Vercel team does to get all of this to work reliably out-of-the-box is truly mind-boggling.
But the second half of the project, the server side stuff, has just been like pulling teeth. It feels like I am trying to mold a glass figurine into a paper airplane. It just doesn't not do stereotypical “server” stuff well, if at all.
And to top it off, the discussion forum posts I've seen of significant amounts of end users coming across the same problems I am seems to be met with an attitude of
We have pondered the options and have come down the mountain with this API for how to make a web app. Oh you need something else? No, I don't think you actually need that.
Unless Vercel is cooking up something behind the scenes that I haven't come across yet, I don't see these things changing anytime soon. Meantime I will probably be looking for a new React framework to champion for, at least here at work.