Not every web project needs a web framework, but when I pick a framework for a project, the main thing I want from it is to do the things that are hard to get right and that nearly every application needs. For me, the gold standard is Ruby on Rails. Out of the box, you get
- database connections for development, test, and production
- database migrations, expressible in either Ruby or SQL, and with
up
anddown
support - sessions that are secure by default, load automatically on first read, and write automatically on change
- the Rack API and the full power of community middleware
- asset compilation, minification, and caching
And that was all in 2012.
I can't think of a single JavaScript framework / meta-framework that comes close to the power of Rails. Astro is my current favorite. I love the focus on content collections and on web-centric APIs like the image optimizer. It still has some catching up to do relative to Rails, Django, and Laravel, especially around interacting with deatabases.
I'm also fairly fond of Remix. I really like the focus on web standards like FormData
. The data-loading and refresh APIs are quite powerful.
While I don't have a single favorite, there is one clear worst choice among JavaScript meta-frameworks: Next.js.
Years ago, I wrote a lot of Ember. One of the guiding principles from their core team was "stability without stagnation." New features came regularly in minor versions. Major versions were really about removing support for old ways of doing things, and nearly every deprecation came with a corresponding codemod to help app developers get their apps onto the new patterns.
The Next.js core team looked at Ember and said, "Bet. We can do instability and stagnation."
In particular, Next.js has at least four completely incompatible APIs for writing an HTTP endpoint. If you have a team writing any moderately large application, you're going to end up with all of the following:
getServerSideProps
in pages/*
running on the serverless
(Node) runtime. The function signature is (GetServerSidePropsContext) => Promise<GetServerSidePropsResult>
. The context includes Node's IncomingMessage
and ServerResponse
. In typical Node fashion, the res
exists before the handler is invokes. Redirects and not-found errors have their own special return value structure.
handler
functions in pages/api/*
running on the serverless
(Node) runtime. The function signature is (NextRequest, NextResponse) => void
, where NextRequest
and NextResponse
are extensions of the Node IncomingMessage
and ServerResponse
APIs.
handler
functions in app/**/route.{js,ts}
running on the edge
(web standards) runtime. The function signature is (NextRequest) => NextResponse
, where NextRequest
and NextResponse
are extensions of the web-standard Request and Response APIs.
Page
functions in app/**/page.{jsx,tsx}
running on the edge
(web standards) runtime. The function signature is ({params, searchParams, request}) => ReactNode
. It's not totally clear what the actual types are because Next.js's TypeScript docs for the App Router don't discuss how to type a Page. But the request
is definitely a web-standards Request
or a subclass thereof.
It's certainly annoying to have to remember all four of those APIs in one app. But that isn't my gripe. My gripe is that it's impossible to write a shared library that handles core concerns like authentication, authorization, A-B testing, feature-flagging, or flash messaging in a way that's compatible with all four.
You can certainly try. A "simple" request-logging middleware might look like this:
/**
* `handler` is a getServerSideProps or a `handler` or a `GET` or a `Page`.
* I'm too tired[^1] to try to define the types for all four of those.
*
* [^1]: I'm tired because I've been fighting with Next.js all day.
*/
function withRequestLogging(handler) {
return function handlerWithRequestLogging(contextOrReq, responseOrUndefined) {
if (contextOrReq != null && 'req' in contextOrReq && 'res' in contextOrReq) {
// getServerSideProps
const { req } = contextOrReq
// req.url is called "url" but it's just the pathname
console.log(`${req.method} ${new URL(req.url, req.headers.host)}`)
return handler(contextOrReq);
}
if (contextOrReq?.headers != null && 'get' in contextOrReq.headers) {
// web standards
const req = contextOrReq
console.log(`${req.method} ${req.url}`)
return handler(contextOrReq, responseOrUndefined)
}
// Node API route
const req = contextOrReq
// req.url is called "url" but it's just the pathname
console.log(`${req.method} ${new URL(req.url, req.headers.host)}`)
return handler(contextOrReq, responseOrUndefined)
}
}
Auth0 makes a valiant attempt at exactly this with their auth0/nextjs-auth0 library. The user has to import auth0
from @auth0/nextjs-auth0/client
or @auth0/nextjs-auth0/edge
depending on where they're using it, but the APIs are at least consistent across the two runtimes.
But note this in the readme:
Server Components in the App Directory (including Pages and Layouts) cannot write to a cookie.
That means that if the middleware detects it needs to update the session, it cannot. It will just silently fail to update the session.
A session middleware can't reliably update the session. A flash-messaging middleware can't reliably mark messages as consumed. A server-timing middleware can't reliably set the server-timing
response header.
This sort of encapsulation of shared logic is exactly what I want a framework to enable. I don't want my team to have to maintain a complex list of request guard functions to call in every single endpoint. I want to centralize as much as possible and distribute the detailed configuration.
I want to centralize things like server-timing
and session
, which don't vary by route.
// middleware.ts
export const middleware = stack(
serverTimingMiddleware(),
cookieSession('my very long secret'),
);
I want to distribute configuration for things that do vary by route, and I want to separate it from the routes' core business logic:
export const route = {
@requireAuth
@requireRoles('User', 'Read')
@requireFeatures('2025-01-new-cache')
get() {
// the actual domain logic
}
}
I hear some readers responding: "but Next.js has a first-class Middleware API!"
It's definitely an API. I'd call it steerage at best.
The first major problem is that it only runs in the edge
runtime. The only thing it can pass to the app is strings in the form of HTTP headers. It's not possible, for example, to create a session
middleware that automatically updates the cookie if a route modifies a value.
The second major problem is that there's only one. If I want different behavior at the middleware layer for different routes, I have to do a regular expression match on the route pathname in a giant switch
statement.
Every time I join a project using Next.js, I immediately add a ticket to the backlog: "Move off Next.js."